diff --git a/docs/api/sqlite.md b/docs/api/sqlite.md index 6bc278aa0ce729..03766152f5daba 100644 --- a/docs/api/sqlite.md +++ b/docs/api/sqlite.md @@ -16,7 +16,10 @@ Features include: - Parameters (named & positional) - Prepared statements - Datatype conversions (`BLOB` becomes `Uint8Array`) +- World's Tiniest ORM (`query.as(MyClass)`) - The fastest performance of any SQLite driver for JavaScript +- `bigint` support +- Multi-query statements (e.g. `SELECT 1; SELECT 2;`) in a single call to database.run(query) The `bun:sqlite` module is roughly 3-6x faster than `better-sqlite3` and 8-9x faster than `deno.land/x/sqlite` for read queries. Each driver was benchmarked against the [Northwind Traders](https://github.com/jpwhite3/northwind-SQLite3/blob/46d5f8a64f396f87cd374d1600dbf521523980e8/Northwind_large.sqlite.zip) dataset. View and run the [benchmark source](https://github.com/oven-sh/bun/tree/main/bench/sqlite). @@ -57,6 +60,29 @@ import { Database } from "bun:sqlite"; const db = new Database("mydb.sqlite", { create: true }); ``` +### Strict mode + +{% callout %} +Added in Bun v1.1.14 +{% /callout %} + +By default, `bun:sqlite` requires binding parameters to include the `$`, `:`, or `@` prefix, and does not throw an error if a parameter is missing. + +To instead throw an error when a parameter is missing and allow binding without a prefix, set `strict: true` on the `Database` constructor: + +```ts +import { Database } from "bun:sqlite"; + +const strict = new Database(":memory:", { strict: true }); +const notStrict = new Database(":memory:"); + +// does not throw error: +notStrict.query("SELECT $message;").all({ messag: "Hello world" }); + +// throws error because of the typo: +const query = strict.query("SELECT $message;").all({ messag: "Hello world" }); +``` + ### Load via ES module import You can also use an import attribute to load a database. @@ -174,6 +200,47 @@ const query = db.query(`SELECT $param1, $param2;`); Values are bound to these parameters when the query is executed. A `Statement` can be executed with several different methods, each returning the results in a different form. +### Binding values + +To bind values to a statement, pass an object to the `.all()`, `.get()`, `.run()`, or `.values()` method. + +```ts +const query = db.query(`select $message;`); +query.all({ $message: "Hello world" }); +``` + +You can bind using positional parameters too: + +```ts +const query = db.query(`select ?1;`); +query.all("Hello world"); +``` + +#### `strict: true` lets you bind values without prefixes + +{% callout %} +Added in Bun v1.1.14 +{% /callout %} + +By default, the `$`, `:`, and `@` prefixes are **included** when binding values to named parameters. To bind without these prefixes, use the `bind` + +```ts +import { Database } from "bun:sqlite"; + +const db = new Database(":memory:", { + // bind values without prefixes + strict: true, +}); + +const query = db.query(`select $message;`); + +// strict: true +query.all({ message: "Hello world" }); + +// strict: false +// query.all({ $message: "Hello world" }); +``` + ### `.all()` Use `.all()` to run a query and get back the results as an array of objects. @@ -205,11 +272,49 @@ Use `.run()` to run a query and get back `undefined`. This is useful for schema- ```ts const query = db.query(`create table foo;`); query.run(); -// => undefined +// { +// lastInsertRowid: 0, +// changes: 0, +// } ``` Internally, this calls [`sqlite3_reset`](https://www.sqlite.org/capi3ref.html#sqlite3_reset) and calls [`sqlite3_step`](https://www.sqlite.org/capi3ref.html#sqlite3_step) once. Stepping through all the rows is not necessary when you don't care about the results. +{% callout %} +Since Bun v1.1.14, `.run()` returns an object with two properties: `lastInsertRowid` and `changes`. +{% /callout %} + +The `lastInsertRowid` property returns the ID of the last row inserted into the database. The `changes` property is the number of rows affected by the query. + +### `.as(Class)` - World's smallest ORM + +{% callout %} +Added in Bun v1.1.14 +{% /callout %} + +Use `.as(Class)` to run a query and get back the results as instances of a class. This lets you attach methods & getters/setters to results. + +```ts +class Movie { + title: string; + year: number; + + get isMarvel() { + return this.title.includes("Marvel"); + } +} + +const query = db.query("SELECT title, year FROM movies").as(Movie); +const movies = query.all(); +const first = query.get(); +console.log(movies[0].isMarvel); // => true +console.log(first.isMarvel); // => true +``` + +As a performance optimization, the class constructor is not called, default initializers are not run, and private fields are not accessible. This is more like using `Object.create` than `new`. The class's prototype is assigned to the object, methods are attached, and getters/setters are set up, but the constructor is not called. + +The database columns are set as properties on the class instance. + ### `.values()` Use `values()` to run a query and get back all results as an array of arrays. @@ -300,6 +405,65 @@ const results = query.all("hello", "goodbye"); {% /codetabs %} +## Integers + +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 `safeInteger` option to `true` when creating a `Database` instance. This also validates that `bigint` passed to `bun:sqlite` do not exceed 64 bits. + +By default, `bun:sqlite` returns integers as `number` types. If you need to handle integers larger than 2^53, you can use the `bigint` type. + +### `safeIntegers: true` + +{% callout %} +Added in Bun v1.1.14 +{% /callout %} + +When `safeIntegers` is `true`, `bun:sqlite` will return integers as `bigint` types: + +```ts +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(); +console.log(result.max_int); // => 9007199254741093n +``` + +When `safeIntegers` is `true`, `bun:sqlite` will throw an error if a `bigint` value in a bound parameter exceeds 64 bits: + +```ts +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)"); + +try { + query.run({ $value: BigInt(Number.MAX_SAFE_INTEGER) ** 2n }); +} catch (e) { + console.log(e.message); // => BigInt value '81129638414606663681390495662081' is out of range +} +``` + +### `safeIntegers: false` (default) + +When `safeIntegers` is `false`, `bun:sqlite` will return integers as `number` types and truncate any bits beyond 53: + +```ts +import { Database } from "bun:sqlite"; + +const db = new Database(":memory:", { safeIntegers: false }); +const query = db.query( + `SELECT ${BigInt(Number.MAX_SAFE_INTEGER) + 102n} as max_int`, +); +const result = query.get(); +console.log(result.max_int); // => 9007199254741092 +``` + ## Transactions Transactions are a mechanism for executing multiple queries in an _atomic_ way; that is, either all of the queries succeed or none of them do. Create a transaction with the `db.transaction()` method: @@ -447,12 +611,20 @@ class Database { ); query(sql: string): Statement; + run( + sql: string, + params?: SQLQueryBindings, + ): { lastInsertRowid: number; changes: number }; + exec = this.run; } class Statement { all(params: Params): ReturnType[]; get(params: Params): ReturnType | undefined; - run(params: Params): void; + run(params: Params): { + lastInsertRowid: number; + changes: number; + }; values(params: Params): unknown[][]; finalize(): void; // destroy statement and clean up resources @@ -461,6 +633,8 @@ class Statement { columnNames: string[]; // the column names of the result set paramsCount: number; // the number of parameters expected by the statement native: any; // the native object representing the statement + + as(Class: new () => ReturnType): this; } type SQLQueryBindings = diff --git a/packages/bun-types/sqlite.d.ts b/packages/bun-types/sqlite.d.ts index 1f2fd7ff5db1bd..007374f83efdf6 100644 --- a/packages/bun-types/sqlite.d.ts +++ b/packages/bun-types/sqlite.d.ts @@ -82,6 +82,40 @@ declare module "bun:sqlite" { * Equivalent to {@link constants.SQLITE_OPEN_READWRITE} */ readwrite?: boolean; + + /** + * When set to `true`, integers are returned as `bigint` types. + * + * When set to `false`, integers are returned as `number` types and truncated to 52 bits. + * + * @default false + * @since v1.1.14 + */ + safeInteger?: boolean; + + /** + * When set to `false` or `undefined`: + * - Queries missing bound parameters will NOT throw an error + * - Bound named parameters in JavaScript need to exactly match the SQL query. + * + * @example + * ```ts + * const db = new Database(":memory:", { strict: false }); + * db.run("INSERT INTO foo (name) VALUES ($name)", { $name: "foo" }); + * ``` + * + * When set to `true`: + * - Queries missing bound parameters will throw an error + * - Bound named parameters in JavaScript no longer need to be `$`, `:`, or `@`. The SQL query will remain prefixed. + * + * @example + * ```ts + * const db = new Database(":memory:", { strict: true }); + * db.run("INSERT INTO foo (name) VALUES ($name)", { name: "foo" }); + * ``` + * @since v1.1.14 + */ + strict?: boolean; }, ); @@ -165,11 +199,11 @@ declare module "bun:sqlite" { * | `bigint` | `INTEGER` | * | `null` | `NULL` | */ - run(sqlQuery: string, ...bindings: ParamsType[]): void; + run(sqlQuery: string, ...bindings: ParamsType[]): Changes; /** This is an alias of {@link Database.prototype.run} */ - exec(sqlQuery: string, ...bindings: ParamsType[]): void; + exec(sqlQuery: string, ...bindings: ParamsType[]): Changes; /** * Compile a SQL query and return a {@link Statement} object. This is the @@ -575,7 +609,7 @@ declare module "bun:sqlite" { * | `bigint` | `INTEGER` | * | `null` | `NULL` | */ - run(...params: ParamsType): void; + run(...params: ParamsType): Changes; /** * Execute the prepared statement and return the results as an array of arrays. @@ -680,6 +714,44 @@ declare module "bun:sqlite" { */ toString(): string; + /** + * + * Make {@link get} and {@link all} return an instance of the provided + * `Class` instead of the default `Object`. + * + * @param Class A class to use + * @returns The same statement instance, modified to return an instance of `Class` + * + * This lets you attach methods, getters, and setters to the returned + * objects. + * + * For performance reasons, constructors for classes are not called, which means + * initializers will not be called and private fields will not be + * accessible. + * + * @example + * + * ## Custom class + * ```ts + * class User { + * rawBirthdate: string; + * get birthdate() { + * return new Date(this.rawBirthdate); + * } + * } + * + * const db = new Database(":memory:"); + * db.exec("CREATE TABLE users (id INTEGER PRIMARY KEY, rawBirthdate TEXT)"); + * db.run("INSERT INTO users (rawBirthdate) VALUES ('1995-12-19')"); + * const query = db.query("SELECT * FROM users"); + * query.as(User); + * const user = query.get(); + * console.log(user.birthdate); + * // => Date(1995, 11, 19) + * ``` + */ + as(Class: new (...args: any[]) => T): Statement; + /** * Native object representing the underlying `sqlite3_stmt` * @@ -1040,4 +1112,21 @@ declare module "bun:sqlite" { */ readonly byteOffset: number; } + + /** + * An object representing the changes made to the database since the last `run` or `exec` call. + * + * @since Bun v1.1.14 + */ + interface Changes { + /** + * The number of rows changed by the last `run` or `exec` call. + */ + changes: number; + + /** + * If `safeIntegers` is `true`, this is a `bigint`. Otherwise, it is a `number`. + */ + lastInsertRowid: number | bigint; + } } diff --git a/src/bun.js/bindings/sqlite/JSSQLStatement.cpp b/src/bun.js/bindings/sqlite/JSSQLStatement.cpp index d33732e5d8d15e..efd55a7f75ea72 100644 --- a/src/bun.js/bindings/sqlite/JSSQLStatement.cpp +++ b/src/bun.js/bindings/sqlite/JSSQLStatement.cpp @@ -1,12 +1,20 @@ + #include "root.h" +#include "JavaScriptCore/Error.h" +#include "JavaScriptCore/JSBigInt.h" +#include "JavaScriptCore/Structure.h" +#include "JavaScriptCore/ThrowScope.h" + +#include "JavaScriptCore/JSArray.h" #include "JavaScriptCore/ExceptionScope.h" #include "JavaScriptCore/JSArrayBufferView.h" #include "JavaScriptCore/JSType.h" #include "JSSQLStatement.h" #include +#include #include #include @@ -35,8 +43,20 @@ #include "BunBuiltinNames.h" #include "sqlite3_error_codes.h" #include "wtf/BitVector.h" +#include "wtf/FastBitVector.h" +#include "wtf/IsoMalloc.h" #include "wtf/Vector.h" #include +#include "wtf/LazyRef.h" +#include "wtf/text/StringToIntegerConversion.h" +#include + +static constexpr int32_t kSafeIntegersFlag = 1 << 1; +static constexpr int32_t kStrictFlag = 1 << 2; + +#ifndef BREAKING_CHANGES_BUN_1_2 +#define BREAKING_CHANGES_BUN_1_2 0 +#endif /* ******************************************************************************** */ // Lazy Load SQLite on macOS @@ -144,6 +164,12 @@ static inline JSC::JSValue jsNumberFromSQLite(sqlite3_stmt* stmt, unsigned int i return num > INT_MAX || num < INT_MIN ? JSC::jsDoubleNumber(static_cast(num)) : JSC::jsNumber(static_cast(num)); } +static inline JSC::JSValue jsBigIntFromSQLite(JSC::JSGlobalObject* globalObject, sqlite3_stmt* stmt, unsigned int i) +{ + int64_t num = sqlite3_column_int64(stmt, i); + return JSC::JSBigInt::createFrom(globalObject, num); +} + #define CHECK_THIS \ if (UNLIKELY(!castedThis)) { \ throwException(lexicalGlobalObject, scope, createError(lexicalGlobalObject, "Expected SQLStatement"_s)); \ @@ -233,6 +259,7 @@ JSC_DECLARE_CUSTOM_GETTER(jsSqlStatementGetColumnCount); JSC_DECLARE_HOST_FUNCTION(jsSQLStatementSerialize); JSC_DECLARE_HOST_FUNCTION(jsSQLStatementDeserialize); +JSC_DECLARE_HOST_FUNCTION(jsSQLStatementSetPrototypeFunction); JSC_DECLARE_HOST_FUNCTION(jsSQLStatementFunctionFinalize); JSC_DECLARE_HOST_FUNCTION(jsSQLStatementToStringFunction); @@ -240,6 +267,9 @@ JSC_DECLARE_CUSTOM_GETTER(jsSqlStatementGetColumnNames); JSC_DECLARE_CUSTOM_GETTER(jsSqlStatementGetColumnCount); JSC_DECLARE_CUSTOM_GETTER(jsSqlStatementGetParamCount); +JSC_DECLARE_CUSTOM_GETTER(jsSqlStatementGetSafeIntegers); +JSC_DECLARE_CUSTOM_SETTER(jsSqlStatementSetSafeIntegers); + static JSValue createSQLiteError(JSC::JSGlobalObject* globalObject, sqlite3* db) { auto& vm = globalObject->vm(); @@ -273,6 +303,83 @@ static JSValue createSQLiteError(JSC::JSGlobalObject* globalObject, sqlite3* db) return object; } +class SQLiteBindingsMap { +public: + SQLiteBindingsMap() = default; + SQLiteBindingsMap(uint16_t count = 0, bool trimLeadingPrefix = false) + { + this->trimLeadingPrefix = trimLeadingPrefix; + hasLoadedNames = false; + reset(count); + } + + void reset(uint16_t count = 0) + { + ASSERT(count <= std::numeric_limits::max()); + if (this->count != count) { + hasLoadedNames = false; + bindingNames.clear(); + } + this->count = count; + } + + void ensureNamesLoaded(JSC::VM& vm, sqlite3_stmt* stmt) + { + if (hasLoadedNames) + return; + + hasLoadedNames = true; + hasOutOfOrderNames = false; + + size_t count = this->count; + size_t prefixOffset = trimLeadingPrefix ? 1 : 0; + bindingNames.clear(); + + bool hasLoadedBindingNames = false; + size_t indexedCount = 0; + + for (size_t i = 0; i < count; i++) { + const unsigned char* name = reinterpret_cast(sqlite3_bind_parameter_name(stmt, i + 1)); + + // INSERT INTO cats (name, age) VALUES (?, ?) RETURNING name + if (name == nullptr) { + indexedCount++; + if (hasLoadedBindingNames) { + bindingNames[i] = Identifier(Identifier::EmptyIdentifier); + } + continue; + } + + if (!hasLoadedBindingNames) { + bindingNames.resize(count); + hasLoadedBindingNames = true; + } + name += prefixOffset; + size_t namelen = strlen(reinterpret_cast(name)); + if (prefixOffset == 1 && name[0] >= '0' && name[0] <= '9') { + auto integer = WTF::parseInteger(StringView({ name, namelen }), 10); + if (integer.has_value()) { + hasOutOfOrderNames = true; + bindingNames.clear(); + break; + } + } + + WTF::String wtfString = WTF::String::fromUTF8ReplacingInvalidSequences({ name, namelen }); + bindingNames[i] = Identifier::fromString(vm, wtfString); + } + + isOnlyIndexed = indexedCount == count; + } + + Vector bindingNames; + uint16_t count = 0; + bool hasLoadedNames : 1 = false; + bool isOnlyIndexed : 1 = false; + bool trimLeadingPrefix : 1 = false; + bool hasOutOfOrderNames : 1 = false; +}; + class JSSQLStatement : public JSC::JSDestructibleObject { public: using Base = JSC::JSDestructibleObject; @@ -322,15 +429,18 @@ class JSSQLStatement : public JSC::JSDestructibleObject { sqlite3_stmt* stmt; VersionSqlite3* version_db; - uint64_t version; - bool hasExecuted = false; + uint64_t version = 0; // Tracks which columns are valid in the current result set. Used to handle duplicate column names. // The bit at index i is set if the column at index i is valid. WTF::BitVector validColumns; std::unique_ptr columnNames; mutable JSC::WriteBarrier _prototype; mutable JSC::WriteBarrier _structure; + mutable JSC::WriteBarrier userPrototype; size_t extraMemorySize = 0; + SQLiteBindingsMap m_bindingNames = { 0, false }; + bool hasExecuted : 1 = false; + bool useBigInt64 : 1 = false; protected: JSSQLStatement(JSC::Structure* structure, JSDOMGlobalObject& globalObject, sqlite3_stmt* stmt, VersionSqlite3* version_db, int64_t memorySizeChange = 0) @@ -345,12 +455,18 @@ class JSSQLStatement : public JSC::JSDestructibleObject { void finishCreation(JSC::VM& vm); }; +template 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); + if constexpr (!useBigInt64) { + // https://github.com/oven-sh/bun/issues/1536 + return jsNumberFromSQLite(stmt, i); + } else { + // https://github.com/oven-sh/bun/issues/1536 + return jsBigIntFromSQLite(globalObject, stmt, i); + } } case SQLITE_FLOAT: { return jsDoubleNumber(sqlite3_column_double(stmt, i)); @@ -402,12 +518,15 @@ static const HashTableValue JSSQLStatementPrototypeTableValues[] = { { "run"_s, static_cast(JSC::PropertyAttribute::Function), NoIntrinsic, { HashTableValue::NativeFunctionType, jsSQLStatementExecuteStatementFunctionRun, 1 } }, { "get"_s, static_cast(JSC::PropertyAttribute::Function | JSC::PropertyAttribute::DOMJITFunction), NoIntrinsic, { HashTableValue::DOMJITFunctionType, jsSQLStatementExecuteStatementFunctionGet, &DOMJITSignatureForjsSQLStatementExecuteStatementFunctionGet } }, { "all"_s, static_cast(JSC::PropertyAttribute::Function), NoIntrinsic, { HashTableValue::NativeFunctionType, jsSQLStatementExecuteStatementFunctionAll, 1 } }, + { "as"_s, static_cast(JSC::PropertyAttribute::Function), NoIntrinsic, { HashTableValue::NativeFunctionType, jsSQLStatementSetPrototypeFunction, 1 } }, { "values"_s, static_cast(JSC::PropertyAttribute::Function), NoIntrinsic, { HashTableValue::NativeFunctionType, jsSQLStatementExecuteStatementFunctionRows, 1 } }, { "finalize"_s, static_cast(JSC::PropertyAttribute::Function), NoIntrinsic, { HashTableValue::NativeFunctionType, jsSQLStatementFunctionFinalize, 0 } }, { "toString"_s, static_cast(JSC::PropertyAttribute::Function), NoIntrinsic, { HashTableValue::NativeFunctionType, jsSQLStatementToStringFunction, 0 } }, { "columns"_s, static_cast(JSC::PropertyAttribute::ReadOnly | JSC::PropertyAttribute::CustomAccessor), NoIntrinsic, { HashTableValue::GetterSetterType, jsSqlStatementGetColumnNames, 0 } }, { "columnsCount"_s, static_cast(JSC::PropertyAttribute::ReadOnly | JSC::PropertyAttribute::CustomAccessor), NoIntrinsic, { HashTableValue::GetterSetterType, jsSqlStatementGetColumnCount, 0 } }, { "paramsCount"_s, static_cast(JSC::PropertyAttribute::ReadOnly | JSC::PropertyAttribute::CustomAccessor), NoIntrinsic, { HashTableValue::GetterSetterType, jsSqlStatementGetParamCount, 0 } }, + { "safeIntegers"_s, static_cast(JSC::PropertyAttribute::CustomAccessor), NoIntrinsic, { HashTableValue::GetterSetterType, jsSqlStatementGetSafeIntegers, jsSqlStatementSetSafeIntegers } }, + }; class JSSQLStatementPrototype final : public JSC::JSNonFinalObject { @@ -519,7 +638,8 @@ static void initializeColumnNames(JSC::JSGlobalObject* lexicalGlobalObject, JSSQ if (LIKELY(!anyHoles)) { PropertyOffset offset; - Structure* structure = globalObject.structureCache().emptyObjectStructureForPrototype(&globalObject, globalObject.objectPrototype(), columnNames->size()); + JSObject* prototype = castedThis->userPrototype ? castedThis->userPrototype.get() : globalObject.objectPrototype(); + Structure* structure = globalObject.structureCache().emptyObjectStructureForPrototype(&globalObject, prototype, columnNames->size()); vm.writeBarrier(castedThis, structure); // We iterated over the columns in reverse order so we need to reverse the columnNames here @@ -550,7 +670,8 @@ static void initializeColumnNames(JSC::JSGlobalObject* lexicalGlobalObject, JSSQ // 64 is the maximum we can preallocate here // see https://github.com/oven-sh/bun/issues/987 - JSC::JSObject* object = JSC::constructEmptyObject(lexicalGlobalObject, lexicalGlobalObject->objectPrototype(), std::min(static_cast(count), JSFinalObject::maxInlineCapacity)); + JSObject* prototype = castedThis->userPrototype ? castedThis->userPrototype.get() : lexicalGlobalObject->objectPrototype(); + JSC::JSObject* object = JSC::constructEmptyObject(lexicalGlobalObject, prototype, std::min(static_cast(count), JSFinalObject::maxInlineCapacity)); for (int i = count - 1; i >= 0; i--) { const char* name = sqlite3_column_name(stmt, i); @@ -604,7 +725,7 @@ void JSSQLStatement::destroy(JSC::JSCell* cell) thisObject->~JSSQLStatement(); } -static inline bool rebindValue(JSC::JSGlobalObject* lexicalGlobalObject, sqlite3* db, sqlite3_stmt* stmt, int i, JSC::JSValue value, JSC::ThrowScope& scope, bool clone) +static inline bool rebindValue(JSC::JSGlobalObject* lexicalGlobalObject, sqlite3* db, sqlite3_stmt* stmt, int i, JSC::JSValue value, JSC::ThrowScope& scope, bool clone, bool isSafeInteger) { auto throwSQLiteError = [&]() -> void { throwException(lexicalGlobalObject, scope, createError(lexicalGlobalObject, WTF::String::fromUTF8(sqlite3_errmsg(db)))); @@ -659,7 +780,21 @@ static inline bool rebindValue(JSC::JSGlobalObject* lexicalGlobalObject, sqlite3 } } else if (UNLIKELY(value.isHeapBigInt())) { - CHECK_BIND(sqlite3_bind_int64(stmt, i, JSBigInt::toBigInt64(value))); + if (!isSafeInteger) { + 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 (LIKELY((min == JSBigInt::ComparisonResult::GreaterThan || min == JSBigInt::ComparisonResult::Equal) && (max == JSBigInt::ComparisonResult::LessThan || max == JSBigInt::ComparisonResult::Equal))) { + 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; + } + } + } else if (JSC::JSArrayBufferView* buffer = JSC::jsDynamicCast(value)) { CHECK_BIND(sqlite3_bind_blob(stmt, i, buffer->vector(), buffer->byteLength(), transientOrStatic)); } else { @@ -671,69 +806,180 @@ static inline bool rebindValue(JSC::JSGlobalObject* lexicalGlobalObject, sqlite3 #undef CHECK_BIND } -// this function does the equivalent of -// Object.entries(obj) -// except without the intermediate array of arrays -static JSC::JSValue rebindObject(JSC::JSGlobalObject* globalObject, JSC::JSValue targetValue, JSC::ThrowScope& scope, sqlite3* db, sqlite3_stmt* stmt, bool clone) +static JSC::JSValue rebindObject(JSC::JSGlobalObject* globalObject, SQLiteBindingsMap& bindings, JSC::JSObject* target, JSC::ThrowScope& scope, sqlite3* db, sqlite3_stmt* stmt, bool clone, bool safeIntegers) { - JSObject* target = targetValue.toObject(globalObject); - RETURN_IF_EXCEPTION(scope, {}); - JSC::VM& vm = globalObject->vm(); - PropertyNameArray properties(vm, PropertyNameMode::Strings, PrivateSymbolMode::Exclude); - target->methodTable()->getOwnPropertyNames(target, globalObject, properties, DontEnumPropertiesMode::Include); - RETURN_IF_EXCEPTION(scope, {}); int count = 0; - for (const auto& propertyName : properties) { - PropertySlot slot(target, PropertySlot::InternalMethodType::GetOwnProperty); - bool hasProperty = target->methodTable()->getOwnPropertySlot(target, globalObject, propertyName, slot); - RETURN_IF_EXCEPTION(scope, JSValue()); - if (!hasProperty) - continue; - if (slot.attributes() & PropertyAttribute::DontEnum) - continue; + JSC::VM& vm = globalObject->vm(); + auto& structure = *target->structure(); + bindings.ensureNamesLoaded(vm, stmt); + const auto& bindingNames = bindings.bindingNames; + size_t size = bindings.count; + + const bool trimLeadingPrefix = bindings.trimLeadingPrefix; + const bool throwOnMissing = trimLeadingPrefix; + + // Did they reorder the columns? + // + // { ?2: "foo", ?1: "bar" } + // + if (UNLIKELY(bindings.hasOutOfOrderNames)) { + + const auto& getValue = [&](const char* name, size_t i) -> JSValue { + JSValue value = {}; + if (name == nullptr) { + return target->getDirectIndex(globalObject, i); + } + + if (trimLeadingPrefix) { + name += 1; + } - JSValue value; - if (LIKELY(!slot.isTaintedByOpaqueObject())) - value = slot.getValue(globalObject, propertyName); - else { - value = target->get(globalObject, propertyName); + const WTF::String str = WTF::String::fromUTF8ReplacingInvalidSequences({ reinterpret_cast(name), strlen(name) }); + + if (trimLeadingPrefix && name[0] >= '0' && name[0] <= '9') { + auto integer = WTF::parseInteger(str, 10); + if (integer.has_value()) { + return target->getDirectIndex(globalObject, integer.value() - 1); + } + } + + const auto identifier = Identifier::fromString(vm, str); + PropertySlot slot(target, PropertySlot::InternalMethodType::GetOwnProperty); + if (!target->getOwnNonIndexPropertySlot(vm, &structure, identifier, slot)) { + return JSValue(); + } + + if (LIKELY(!slot.isTaintedByOpaqueObject())) { + return slot.getValue(globalObject, identifier); + } + + return target->get(globalObject, identifier); + }; + + for (size_t i = 0; i < size; i++) { + auto* name = sqlite3_bind_parameter_name(stmt, i + 1); + + JSValue value = getValue(name, i); + if (!value && !scope.exception()) { + if (throwOnMissing) { + throwException(globalObject, scope, createError(globalObject, makeString("Missing parameter \""_s, name, "\""_s))); + } else { + continue; + } + } RETURN_IF_EXCEPTION(scope, JSValue()); + + if (!rebindValue(globalObject, db, stmt, i + 1, value, scope, clone, safeIntegers)) { + return JSValue(); + } + + RETURN_IF_EXCEPTION(scope, {}); + count++; } + } + // Does it only contain indexed properties? + // + // { 0: "foo", 1: "bar", "2": "baz" } + // + else if (UNLIKELY(bindings.isOnlyIndexed)) { + for (size_t i = 0; i < size; i++) { + JSValue value = target->getDirectIndex(globalObject, i); + if (!value && !scope.exception()) { + if (throwOnMissing) { + throwException(globalObject, scope, createError(globalObject, makeString("Missing parameter \""_s, i + 1, "\""_s))); + } else { + continue; + } + } + + RETURN_IF_EXCEPTION(scope, JSValue()); - // Ensure this gets freed on scope clear - auto utf8 = WTF::String(propertyName.string()).utf8(); + if (!rebindValue(globalObject, db, stmt, i + 1, value, scope, clone, safeIntegers)) { + return JSValue(); + } - int index = sqlite3_bind_parameter_index(stmt, utf8.data()); - if (index == 0) { - throwException(globalObject, scope, createError(globalObject, "Unknown parameter \"" + propertyName.string() + "\""_s)); - return JSValue(); + RETURN_IF_EXCEPTION(scope, {}); + count++; } + } + // Is it a simple object with no getters or setters? + // + // { foo: "bar", baz: "qux" } + // + else if (target->canUseFastGetOwnProperty(structure)) { + for (size_t i = 0; i < size; i++) { + const auto& property = bindingNames[i]; + JSValue value = property.isEmpty() ? target->getDirectIndex(globalObject, i) : target->fastGetOwnProperty(vm, structure, bindingNames[i]); + if (!value && !scope.exception()) { + if (throwOnMissing) { + throwException(globalObject, scope, createError(globalObject, makeString("Missing parameter \""_s, property.isEmpty() ? String::number(i) : property.string(), "\""_s))); + } else { + continue; + } + } - if (!rebindValue(globalObject, db, stmt, index, value, scope, clone)) - return JSValue(); - RETURN_IF_EXCEPTION(scope, {}); - count++; + RETURN_IF_EXCEPTION(scope, JSValue()); + + if (!rebindValue(globalObject, db, stmt, i + 1, value, scope, clone, safeIntegers)) { + return JSValue(); + } + + RETURN_IF_EXCEPTION(scope, {}); + count++; + } + } else { + for (size_t i = 0; i < size; i++) { + PropertySlot slot(target, PropertySlot::InternalMethodType::GetOwnProperty); + const auto& property = bindingNames[i]; + bool hasProperty = property.isEmpty() ? target->methodTable()->getOwnPropertySlotByIndex(target, globalObject, i, slot) : target->methodTable()->getOwnPropertySlot(target, globalObject, property, slot); + if (!hasProperty && !scope.exception()) { + if (throwOnMissing) { + throwException(globalObject, scope, createError(globalObject, makeString("Missing parameter \""_s, property.isEmpty() ? String::number(i) : property.string(), "\""_s))); + } else { + continue; + } + } + + RETURN_IF_EXCEPTION(scope, JSValue()); + + JSValue value; + if (LIKELY(!slot.isTaintedByOpaqueObject())) + value = slot.getValue(globalObject, property); + else { + value = target->get(globalObject, property); + RETURN_IF_EXCEPTION(scope, JSValue()); + } + + RETURN_IF_EXCEPTION(scope, JSValue()); + + if (!rebindValue(globalObject, db, stmt, i + 1, value, scope, clone, safeIntegers)) { + return JSValue(); + } + + RETURN_IF_EXCEPTION(scope, {}); + count++; + } } return jsNumber(count); } -static JSC::JSValue rebindStatement(JSC::JSGlobalObject* lexicalGlobalObject, JSC::JSValue values, JSC::ThrowScope& scope, sqlite3* db, sqlite3_stmt* stmt, bool clone) +static JSC::JSValue rebindStatement(JSC::JSGlobalObject* lexicalGlobalObject, JSC::JSValue values, JSC::ThrowScope& scope, sqlite3* db, sqlite3_stmt* stmt, bool clone, SQLiteBindingsMap& bindings, bool safeIntegers) { sqlite3_clear_bindings(stmt); JSC::JSArray* array = jsDynamicCast(values); - int max = sqlite3_bind_parameter_count(stmt); + bindings.reset(sqlite3_bind_parameter_count(stmt)); if (!array) { if (JSC::JSObject* object = values.getObject()) { - auto res = rebindObject(lexicalGlobalObject, object, scope, db, stmt, clone); + auto res = rebindObject(lexicalGlobalObject, bindings, object, scope, db, stmt, clone, safeIntegers); RETURN_IF_EXCEPTION(scope, {}); return res; } throwException(lexicalGlobalObject, scope, createError(lexicalGlobalObject, "Expected array"_s)); - return jsUndefined(); + return {}; } int count = array->length(); @@ -742,15 +988,18 @@ static JSC::JSValue rebindStatement(JSC::JSGlobalObject* lexicalGlobalObject, JS return jsNumber(0); } - if (count != max) { - throwException(lexicalGlobalObject, scope, createError(lexicalGlobalObject, "Expected " + String::number(max) + " values, got " + String::number(count))); - return jsUndefined(); + int required = bindings.count; + if (count != required) { + throwException(lexicalGlobalObject, scope, createError(lexicalGlobalObject, makeString("SQLite query expected "_s, required, " values, received "_s, count))); + return {}; } int i = 0; for (; i < count; i++) { JSC::JSValue value = array->getIndexQuickly(i); - rebindValue(lexicalGlobalObject, db, stmt, i + 1, value, scope, clone); + if (!rebindValue(lexicalGlobalObject, db, stmt, i + 1, value, scope, clone, safeIntegers)) { + return {}; + } RETURN_IF_EXCEPTION(scope, {}); } @@ -1023,13 +1272,16 @@ JSC_DEFINE_HOST_FUNCTION(jsSQLStatementExecuteFunction, (JSC::JSGlobalObject * l return JSValue::encode(JSC::jsUndefined()); } - JSC::JSValue sqlValue = callFrame->argument(1); + JSC::JSValue internalFlagsValue = callFrame->argument(1); + JSC::JSValue diffValue = callFrame->argument(2); + + JSC::JSValue sqlValue = callFrame->argument(3); if (UNLIKELY(!sqlValue.isString())) { throwException(lexicalGlobalObject, scope, createError(lexicalGlobalObject, "Expected SQL string"_s)); return JSValue::encode(JSC::jsUndefined()); } - EnsureStillAliveScope bindingsAliveScope = callFrame->argumentCount() > 2 ? callFrame->argument(2) : jsUndefined(); + EnsureStillAliveScope bindingsAliveScope = callFrame->argument(4); auto sqlString = sqlValue.toWTFString(lexicalGlobalObject); if (UNLIKELY(sqlString.length() == 0)) { @@ -1064,6 +1316,11 @@ JSC_DEFINE_HOST_FUNCTION(jsSQLStatementExecuteFunction, (JSC::JSGlobalObject * l int maxSqlStringBytes = end - sqlStringHead; #endif + bool strict = internalFlagsValue.isInt32() && (internalFlagsValue.asInt32() & kStrictFlag) != 0; + bool safeIntegers = internalFlagsValue.isInt32() && (internalFlagsValue.asInt32() & kSafeIntegersFlag) != 0; + + const int total_changes_before = sqlite3_total_changes(db); + while (sqlStringHead && sqlStringHead < end) { if (UNLIKELY(isSkippedInSQLiteQuery(*sqlStringHead))) { sqlStringHead++; @@ -1094,7 +1351,12 @@ JSC_DEFINE_HOST_FUNCTION(jsSQLStatementExecuteFunction, (JSC::JSGlobalObject * l // First statement gets the bindings. if (!didSetBindings && !bindingsAliveScope.value().isUndefinedOrNull()) { if (bindingsAliveScope.value().isObject()) { - JSC::JSValue reb = rebindStatement(lexicalGlobalObject, bindingsAliveScope.value(), scope, db, sql.stmt, false); + 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, false, bindings, safeIntegers); + RETURN_IF_EXCEPTION(scope, {}); + if (UNLIKELY(!reb.isNumber())) { return JSValue::encode(reb); /* this means an error */ } @@ -1123,6 +1385,17 @@ JSC_DEFINE_HOST_FUNCTION(jsSQLStatementExecuteFunction, (JSC::JSGlobalObject * l return JSValue::encode(JSC::jsUndefined()); } + if (auto* diff = JSC::jsDynamicCast(diffValue)) { + const int total_changes_after = sqlite3_total_changes(db); + int64_t last_insert_rowid = sqlite3_last_insert_rowid(db); + diff->putInternalField(vm, 0, JSC::jsNumber(total_changes_after - total_changes_before)); + if (safeIntegers) { + diff->putInternalField(vm, 1, JSBigInt::createFrom(lexicalGlobalObject, last_insert_rowid)); + } else { + diff->putInternalField(vm, 1, JSC::jsNumber(last_insert_rowid)); + } + } + return JSValue::encode(jsUndefined()); } @@ -1178,6 +1451,7 @@ JSC_DEFINE_HOST_FUNCTION(jsSQLStatementPrepareStatementFunction, (JSC::JSGlobalO JSC::JSValue sqlValue = callFrame->argument(1); JSC::JSValue bindings = callFrame->argument(2); JSC::JSValue prepareFlagsValue = callFrame->argument(3); + JSC::JSValue internalFlagsValue = callFrame->argument(4); if (!dbNumber.isNumber() || !sqlValue.isString()) { throwException(lexicalGlobalObject, scope, createError(lexicalGlobalObject, "SQLStatement requires a number and a string"_s)); @@ -1240,6 +1514,12 @@ JSC_DEFINE_HOST_FUNCTION(jsSQLStatementPrepareStatementFunction, (JSC::JSGlobalO JSSQLStatement* sqlStatement = JSSQLStatement::create( reinterpret_cast(lexicalGlobalObject), statement, databases()[handle], memoryChange); + if (internalFlagsValue.isInt32()) { + const int32_t internalFlags = internalFlagsValue.asInt32(); + sqlStatement->m_bindingNames.trimLeadingPrefix = (internalFlags & kStrictFlag) != 0; + sqlStatement->useBigInt64 = (internalFlags & kSafeIntegersFlag) != 0; + } + if (bindings.isObject()) { auto* castedThis = sqlStatement; DO_REBIND(bindings) @@ -1314,6 +1594,8 @@ JSC_DEFINE_HOST_FUNCTION(jsSQLStatementOpenStatementFunction, (JSC::JSGlobalObje return JSValue::encode(jsUndefined()); } + sqlite3_extended_result_codes(db, 1); + int status = sqlite3_db_config(db, SQLITE_DBCONFIG_ENABLE_LOAD_EXTENSION, 1, NULL); if (status != SQLITE_OK) { // TODO: log a warning here that extensions are unsupported. @@ -1324,7 +1606,7 @@ JSC_DEFINE_HOST_FUNCTION(jsSQLStatementOpenStatementFunction, (JSC::JSGlobalObje // TODO: log a warning here that defensive mode is unsupported. } auto index = databases().size(); - sqlite3_extended_result_codes(db, 1); + databases().append(new VersionSqlite3(db)); if (finalizationTarget.isObject()) { vm.heap.addFinalizer(finalizationTarget.getObject(), [index](JSC::JSCell* ptr) -> void { @@ -1509,7 +1791,7 @@ void JSSQLStatementConstructor::finishCreation(VM& vm) ASSERT(inherits(info())); } -static inline JSC::JSValue constructResultObject(JSC::JSGlobalObject* lexicalGlobalObject, JSSQLStatement* castedThis); +template static inline JSC::JSValue constructResultObject(JSC::JSGlobalObject* lexicalGlobalObject, JSSQLStatement* castedThis) { auto& columnNames = castedThis->columnNames->data()->propertyNameVector(); @@ -1533,14 +1815,15 @@ static inline JSC::JSValue constructResultObject(JSC::JSGlobalObject* lexicalGlo j -= 1; continue; } - result->putDirectOffset(vm, j, toJS(vm, lexicalGlobalObject, stmt, i)); + result->putDirectOffset(vm, j, toJS(vm, lexicalGlobalObject, stmt, i)); } } else { if (count <= JSFinalObject::maxInlineCapacity) { result = JSC::JSFinalObject::create(vm, castedThis->_prototype.get()->structure()); } else { - result = JSC::JSFinalObject::create(vm, JSC::JSFinalObject::createStructure(vm, lexicalGlobalObject, lexicalGlobalObject->objectPrototype(), JSFinalObject::maxInlineCapacity)); + JSObject* prototype = castedThis->userPrototype ? castedThis->userPrototype.get() : lexicalGlobalObject->objectPrototype(); + result = JSC::JSFinalObject::create(vm, JSC::JSFinalObject::createStructure(vm, lexicalGlobalObject, prototype, JSFinalObject::maxInlineCapacity)); } for (int i = 0, j = 0; j < count; i++, j++) { @@ -1548,51 +1831,8 @@ static inline JSC::JSValue constructResultObject(JSC::JSGlobalObject* lexicalGlo j -= 1; continue; } - auto name = columnNames[j]; - result->putDirect(vm, name, toJS(vm, lexicalGlobalObject, stmt, i), 0); - - 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[j]; + result->putDirect(vm, name, toJS(vm, lexicalGlobalObject, stmt, i), 0); } } @@ -1606,10 +1846,18 @@ static inline JSC::JSArray* constructResultRow(JSC::VM& vm, JSC::JSGlobalObject* MarkedArgumentBuffer arguments; arguments.ensureCapacity(columnCount); - for (size_t i = 0; i < columnCount; i++) { - JSValue value = toJS(vm, lexicalGlobalObject, stmt, i); - RETURN_IF_EXCEPTION(throwScope, nullptr); - arguments.append(value); + if (castedThis->useBigInt64) { + for (size_t i = 0; i < columnCount; i++) { + JSValue value = toJS(vm, lexicalGlobalObject, stmt, i); + RETURN_IF_EXCEPTION(throwScope, nullptr); + arguments.append(value); + } + } else { + for (size_t i = 0; i < columnCount; i++) { + JSValue value = toJS(vm, lexicalGlobalObject, stmt, i); + RETURN_IF_EXCEPTION(throwScope, nullptr); + arguments.append(value); + } } JSC::ObjectInitializationScope initializationScope(vm); @@ -1628,6 +1876,67 @@ static inline JSC::JSArray* constructResultRow(JSC::VM& vm, JSC::JSGlobalObject* return result; } +JSC_DEFINE_HOST_FUNCTION(jsSQLStatementSetPrototypeFunction, (JSGlobalObject * lexicalGlobalObject, JSC::CallFrame* callFrame)) +{ + JSC::VM& vm = lexicalGlobalObject->vm(); + auto scope = DECLARE_THROW_SCOPE(vm); + auto* castedThis = jsCast(callFrame->thisValue()); + + CHECK_THIS + + JSValue classValue = callFrame->argument(0); + + if (classValue.isObject()) { + JSObject* classObject = classValue.getObject(); + if (classObject == lexicalGlobalObject->objectConstructor()) { + castedThis->userPrototype.clear(); + + // Force the prototypes to be re-created + if (castedThis->version_db) { + castedThis->version_db->version++; + } + + return JSValue::encode(jsUndefined()); + } + + if (!classObject->isConstructor()) { + throwTypeError(lexicalGlobalObject, scope, "Expected a constructor"_s); + return JSValue::encode(jsUndefined()); + } + + JSValue prototype = classObject->getIfPropertyExists(lexicalGlobalObject, vm.propertyNames->prototype); + if (UNLIKELY(!prototype && !scope.exception())) { + throwTypeError(lexicalGlobalObject, scope, "Expected constructor to have a prototype"_s); + } + + RETURN_IF_EXCEPTION(scope, JSValue::encode(jsUndefined())); + + if (!prototype.isObject()) { + throwTypeError(lexicalGlobalObject, scope, "Expected a constructor prototype to be an object"_s); + return {}; + } + + castedThis->userPrototype.set(vm, classObject, prototype.getObject()); + + // Force the prototypes to be re-created + if (castedThis->version_db) { + castedThis->version_db->version++; + } + } else if (classValue.isUndefined()) { + castedThis->userPrototype.clear(); + + // Force the prototypes to be re-created + if (castedThis->version_db) { + castedThis->version_db->version++; + } + } else { + throwTypeError(lexicalGlobalObject, scope, "Expected class to be a constructor or undefined"_s); + return JSValue::encode(jsUndefined()); + } + + return JSValue::encode(jsUndefined()); +} + JSC_DEFINE_HOST_FUNCTION(jsSQLStatementExecuteStatementFunctionAll, (JSC::JSGlobalObject * lexicalGlobalObject, JSC::CallFrame* callFrame)) { JSC::VM& vm = lexicalGlobalObject->vm(); @@ -1672,14 +1981,20 @@ JSC_DEFINE_HOST_FUNCTION(jsSQLStatementExecuteStatementFunctionAll, (JSC::JSGlob status = sqlite3_step(stmt); } } else { - + bool useBigInt64 = castedThis->useBigInt64; JSC::JSArray* resultArray = JSC::constructEmptyArray(lexicalGlobalObject, nullptr, 0); - { - while (status == SQLITE_ROW) { - JSC::JSValue result = constructResultObject(lexicalGlobalObject, castedThis); + if (useBigInt64) { + do { + JSC::JSValue result = constructResultObject(lexicalGlobalObject, castedThis); resultArray->push(lexicalGlobalObject, result); status = sqlite3_step(stmt); - } + } while (status == SQLITE_ROW); + } else { + do { + JSC::JSValue result = constructResultObject(lexicalGlobalObject, castedThis); + resultArray->push(lexicalGlobalObject, result); + status = sqlite3_step(stmt); + } while (status == SQLITE_ROW); } result = resultArray; } @@ -1735,7 +2050,10 @@ JSC_DEFINE_HOST_FUNCTION(jsSQLStatementExecuteStatementFunctionGet, (JSC::JSGlob JSValue result = jsNull(); if (status == SQLITE_ROW) { - result = constructResultObject(lexicalGlobalObject, castedThis); + bool useBigInt64 = castedThis->useBigInt64; + + result = useBigInt64 ? constructResultObject(lexicalGlobalObject, castedThis) + : constructResultObject(lexicalGlobalObject, castedThis); while (status == SQLITE_ROW) { status = sqlite3_step(stmt); } @@ -1779,7 +2097,10 @@ JSC_DEFINE_JIT_OPERATION(jsSQLStatementExecuteStatementFunctionGetWithoutTypeChe JSValue result = jsNull(); if (status == SQLITE_ROW) { - result = constructResultObject(lexicalGlobalObject, castedThis); + bool useBigInt64 = castedThis->useBigInt64; + + result = useBigInt64 ? constructResultObject(lexicalGlobalObject, castedThis) + : constructResultObject(lexicalGlobalObject, castedThis); while (status == SQLITE_ROW) { status = sqlite3_step(stmt); } @@ -1891,8 +2212,10 @@ JSC_DEFINE_HOST_FUNCTION(jsSQLStatementExecuteStatementFunctionRun, (JSC::JSGlob return JSValue::encode(jsUndefined()); } - if (callFrame->argumentCount() > 0) { - auto arg0 = callFrame->argument(0); + JSValue diffValue = callFrame->argument(0); + + if (callFrame->argumentCount() > 1) { + auto arg0 = callFrame->argument(1); DO_REBIND(arg0); } @@ -1905,6 +2228,8 @@ JSC_DEFINE_HOST_FUNCTION(jsSQLStatementExecuteStatementFunctionRun, (JSC::JSGlob initializeColumnNames(lexicalGlobalObject, castedThis); } + int total_changes_before = sqlite3_total_changes(castedThis->version_db->db); + while (status == SQLITE_ROW) { status = sqlite3_step(stmt); } @@ -1915,6 +2240,18 @@ JSC_DEFINE_HOST_FUNCTION(jsSQLStatementExecuteStatementFunctionRun, (JSC::JSGlob return JSValue::encode(jsUndefined()); } + if (auto* diff = JSC::jsDynamicCast(diffValue)) { + auto* db = castedThis->version_db->db; + const int total_changes_after = sqlite3_total_changes(db); + int64_t last_insert_rowid = sqlite3_last_insert_rowid(db); + diff->putInternalField(vm, 0, JSC::jsNumber(total_changes_after - total_changes_before)); + if (castedThis->useBigInt64) { + diff->putInternalField(vm, 1, JSBigInt::createFrom(lexicalGlobalObject, last_insert_rowid)); + } else { + diff->putInternalField(vm, 1, JSC::jsNumber(last_insert_rowid)); + } + } + RELEASE_AND_RETURN(scope, JSC::JSValue::encode(jsUndefined())); } @@ -1987,6 +2324,32 @@ JSC_DEFINE_CUSTOM_GETTER(jsSqlStatementGetParamCount, (JSGlobalObject * lexicalG RELEASE_AND_RETURN(scope, JSC::JSValue::encode(JSC::jsNumber(sqlite3_bind_parameter_count(castedThis->stmt)))); } +JSC_DEFINE_CUSTOM_GETTER(jsSqlStatementGetSafeIntegers, (JSGlobalObject * lexicalGlobalObject, JSC::EncodedJSValue thisValue, PropertyName attributeName)) +{ + JSC::VM& vm = lexicalGlobalObject->vm(); + JSSQLStatement* castedThis = jsDynamicCast(JSValue::decode(thisValue)); + auto scope = DECLARE_THROW_SCOPE(vm); + CHECK_THIS + CHECK_PREPARED + + RELEASE_AND_RETURN(scope, JSC::JSValue::encode(JSC::jsBoolean(castedThis->useBigInt64))); +} + +JSC_DEFINE_CUSTOM_SETTER(jsSqlStatementSetSafeIntegers, (JSGlobalObject * lexicalGlobalObject, JSC::EncodedJSValue thisValue, JSC::EncodedJSValue encodedValue, PropertyName attributeName)) +{ + JSC::VM& vm = lexicalGlobalObject->vm(); + JSSQLStatement* castedThis = jsDynamicCast(JSValue::decode(thisValue)); + auto scope = DECLARE_THROW_SCOPE(vm); + CHECK_THIS + CHECK_PREPARED + + bool value = JSValue::decode(encodedValue).toBoolean(lexicalGlobalObject); + RETURN_IF_EXCEPTION(scope, false); + castedThis->useBigInt64 = value; + + return true; +} + JSC_DEFINE_HOST_FUNCTION(jsSQLStatementFunctionFinalize, (JSC::JSGlobalObject * lexicalGlobalObject, JSC::CallFrame* callFrame)) { JSC::VM& vm = lexicalGlobalObject->vm(); @@ -2040,7 +2403,8 @@ JSC::JSValue JSSQLStatement::rebind(JSC::JSGlobalObject* lexicalGlobalObject, JS JSC::VM& vm = lexicalGlobalObject->vm(); auto scope = DECLARE_THROW_SCOPE(vm); auto* stmt = this->stmt; - auto val = rebindStatement(lexicalGlobalObject, values, scope, this->version_db->db, stmt, clone); + + auto val = rebindStatement(lexicalGlobalObject, values, scope, this->version_db->db, stmt, clone, this->m_bindingNames, this->useBigInt64); if (val.isNumber()) { RELEASE_AND_RETURN(scope, val); } else { @@ -2059,6 +2423,7 @@ void JSSQLStatement::visitChildrenImpl(JSCell* cell, Visitor& visitor) visitor.append(thisObject->_structure); visitor.append(thisObject->_prototype); + visitor.append(thisObject->userPrototype); } DEFINE_VISIT_CHILDREN(JSSQLStatement); @@ -2071,6 +2436,7 @@ void JSSQLStatement::visitAdditionalChildren(Visitor& visitor) visitor.append(thisObject->_structure); visitor.append(thisObject->_prototype); + visitor.append(thisObject->userPrototype); } template @@ -2088,10 +2454,18 @@ template void JSSQLStatement::visitOutputConstraints(JSCell*, SlotVisitor&); JSValue createJSSQLStatementConstructor(Zig::GlobalObject* globalObject) { VM& vm = globalObject->vm(); - return JSSQLStatementConstructor::create( + JSObject* object = JSC::constructEmptyObject(globalObject); + auto* diff = InternalFieldTuple::create(vm, globalObject->internalFieldTupleStructure(), jsUndefined(), jsUndefined()); + + auto* constructor = JSSQLStatementConstructor::create( vm, globalObject, JSSQLStatementConstructor::createStructure(vm, globalObject, globalObject->m_functionPrototype.get())); + + object->putDirectIndex(globalObject, 0, constructor); + object->putDirectIndex(globalObject, 1, diff); + + return object; } } // namespace WebCore diff --git a/src/bun.js/bindings/sqlite/lazy_sqlite3.h b/src/bun.js/bindings/sqlite/lazy_sqlite3.h index 00a1c292d07d7d..5b2855776aad1b 100644 --- a/src/bun.js/bindings/sqlite/lazy_sqlite3.h +++ b/src/bun.js/bindings/sqlite/lazy_sqlite3.h @@ -43,6 +43,7 @@ typedef char* (*lazy_sqlite3_expanded_sql_type)(sqlite3_stmt* pStmt); typedef int (*lazy_sqlite3_finalize_type)(sqlite3_stmt* pStmt); typedef void (*lazy_sqlite3_free_type)(void*); typedef int (*lazy_sqlite3_get_autocommit_type)(sqlite3*); +typedef int (*lazy_sqlite3_total_changes_type)(sqlite3*); typedef int (*lazy_sqlite3_get_autocommit_type)(sqlite3*); typedef int (*lazy_sqlite3_config_type)(int, ...); typedef int (*lazy_sqlite3_open_v2_type)(const char* filename, /* Database filename (UTF-8) */ sqlite3** ppDb, /* OUT: SQLite db handle */ int flags, /* Flags */ const char* zVfs /* Name of VFS module to use */); @@ -63,6 +64,7 @@ typedef int (*lazy_sqlite3_step_type)(sqlite3_stmt*); typedef int (*lazy_sqlite3_clear_bindings_type)(sqlite3_stmt*); typedef int (*lazy_sqlite3_column_type_type)(sqlite3_stmt*, int iCol); typedef int (*lazy_sqlite3_db_config_type)(sqlite3*, int op, ...); +typedef const char* (*lazy_sqlite3_bind_parameter_name_type)(sqlite3_stmt*, int); typedef int (*lazy_sqlite3_load_extension_type)( sqlite3* db, /* Load the extension into this database connection */ @@ -89,6 +91,7 @@ typedef int (*lazy_sqlite3_deserialize_type)( typedef int (*lazy_sqlite3_stmt_readonly_type)(sqlite3_stmt* pStmt); typedef int (*lazy_sqlite3_compileoption_used_type)(const char* zOptName); +typedef int64_t (*lazy_sqlite3_last_insert_rowid_type)(sqlite3* db); static lazy_sqlite3_bind_blob_type lazy_sqlite3_bind_blob; static lazy_sqlite3_bind_double_type lazy_sqlite3_bind_double; @@ -138,6 +141,9 @@ static lazy_sqlite3_extended_result_codes_type lazy_sqlite3_extended_result_code static lazy_sqlite3_extended_errcode_type lazy_sqlite3_extended_errcode; static lazy_sqlite3_error_offset_type lazy_sqlite3_error_offset; static lazy_sqlite3_memory_used_type lazy_sqlite3_memory_used; +static lazy_sqlite3_bind_parameter_name_type lazy_sqlite3_bind_parameter_name; +static lazy_sqlite3_total_changes_type lazy_sqlite3_total_changes; +static lazy_sqlite3_last_insert_rowid_type lazy_sqlite3_last_insert_rowid; #define sqlite3_bind_blob lazy_sqlite3_bind_blob #define sqlite3_bind_double lazy_sqlite3_bind_double @@ -186,6 +192,9 @@ static lazy_sqlite3_memory_used_type lazy_sqlite3_memory_used; #define sqlite3_extended_errcode lazy_sqlite3_extended_errcode #define sqlite3_error_offset lazy_sqlite3_error_offset #define sqlite3_memory_used lazy_sqlite3_memory_used +#define sqlite3_bind_parameter_name lazy_sqlite3_bind_parameter_name +#define sqlite3_total_changes lazy_sqlite3_total_changes +#define sqlite3_last_insert_rowid lazy_sqlite3_last_insert_rowid #if !OS(WINDOWS) #define HMODULE void* @@ -267,6 +276,9 @@ static int lazyLoadSQLite() lazy_sqlite3_extended_errcode = (lazy_sqlite3_extended_errcode_type)dlsym(sqlite3_handle, "sqlite3_extended_errcode"); lazy_sqlite3_error_offset = (lazy_sqlite3_error_offset_type)dlsym(sqlite3_handle, "sqlite3_error_offset"); lazy_sqlite3_memory_used = (lazy_sqlite3_memory_used_type)dlsym(sqlite3_handle, "sqlite3_memory_used"); + lazy_sqlite3_bind_parameter_name = (lazy_sqlite3_bind_parameter_name_type)dlsym(sqlite3_handle, "sqlite3_bind_parameter_name"); + lazy_sqlite3_total_changes = (lazy_sqlite3_total_changes_type)dlsym(sqlite3_handle, "sqlite3_total_changes"); + lazy_sqlite3_last_insert_rowid = (lazy_sqlite3_last_insert_rowid_type)dlsym(sqlite3_handle, "sqlite3_last_insert_rowid"); if (!lazy_sqlite3_extended_result_codes) { lazy_sqlite3_extended_result_codes = [](sqlite3*, int) -> int { diff --git a/src/js/bun/sqlite.ts b/src/js/bun/sqlite.ts index 6744c569fea41b..73bab306ed3660 100644 --- a/src/js/bun/sqlite.ts +++ b/src/js/bun/sqlite.ts @@ -1,10 +1,26 @@ // Hardcoded module "sqlite" -var defineProperties = Object.defineProperties; +const kSafeIntegersFlag = 1 << 1; +const kStrictFlag = 1 << 2; + +var defineProperties = Object.defineProperties; var toStringTag = Symbol.toStringTag; var isArray = Array.isArray; var isTypedArray = ArrayBuffer.isView; +let internalFieldTuple; + +function initializeSQL() { + ({ 0: SQL, 1: internalFieldTuple } = $cpp("JSSQLStatement.cpp", "createJSSQLStatementConstructor")); +} + +function createChangesObject() { + return { + changes: $getInternalField(internalFieldTuple, 0), + lastInsertRowid: $getInternalField(internalFieldTuple, 1), + }; +} + const constants = { SQLITE_OPEN_READONLY: 0x00000001 /* Ok for sqlite3_open_v2() */, SQLITE_OPEN_READWRITE: 0x00000002 /* Ok for sqlite3_open_v2() */, @@ -143,7 +159,24 @@ class Statement { } #runNoArgs() { - this.#raw.run(); + this.#raw.run(internalFieldTuple); + + return createChangesObject(); + } + + safeIntegers(updatedValue?: boolean) { + if (updatedValue !== undefined) { + this.#raw.safeIntegers = !!updatedValue; + return this; + } + + return this.#raw.safeIntegers; + } + + as(ClassType: any) { + this.#raw.as(ClassType); + + return this; } #get(...args) { @@ -183,12 +216,17 @@ class Statement { } #run(...args) { - if (args.length === 0) return this.#runNoArgs(); + if (args.length === 0) { + this.#runNoArgs(); + return createChangesObject(); + } var arg0 = args[0]; !isArray(arg0) && (!arg0 || typeof arg0 !== "object" || isTypedArray(arg0)) - ? this.#raw.run(args) - : this.#raw.run(...args); + ? this.#raw.run(internalFieldTuple, args) + : this.#raw.run(internalFieldTuple, ...args); + + return createChangesObject(); } get columnNames() { @@ -217,6 +255,16 @@ class Database { if (typeof filenameGiven === "undefined") { } else if (typeof filenameGiven !== "string") { if (isTypedArray(filenameGiven)) { + if (options && typeof options === "object") { + if (options.strict) { + this.#internalFlags |= kStrictFlag; + } + + if (options.safeIntegers) { + this.#internalFlags |= kSafeIntegersFlag; + } + } + this.#handle = Database.#deserialize( filenameGiven, typeof options === "object" && options @@ -224,6 +272,7 @@ class Database { : ((options | 0) & constants.SQLITE_OPEN_READONLY) != 0, ); this.filename = ":memory:"; + return; } @@ -248,6 +297,21 @@ class Database { if (options.readwrite) { flags |= constants.SQLITE_OPEN_READWRITE; } + + if ("strict" in options || "safeIntegers" in options) { + if (options.safeIntegers) { + this.#internalFlags |= kSafeIntegersFlag; + } + + if (options.strict) { + this.#internalFlags |= kStrictFlag; + } + + // If they only set strict: true, reset it back. + if (flags === 0) { + flags = constants.SQLITE_OPEN_READWRITE | constants.SQLITE_OPEN_CREATE; + } + } } else if (typeof options === "number") { flags = options; } @@ -258,13 +322,14 @@ class Database { } if (!SQL) { - SQL = $cpp("JSSQLStatement.cpp", "createJSSQLStatementConstructor"); + initializeSQL(); } this.#handle = SQL.open(anonymous ? ":memory:" : filename, flags, this); this.filename = filename; } + #internalFlags = 0; #handle; #cachedQueriesKeys = []; #cachedQueriesLengths = []; @@ -293,7 +358,7 @@ class Database { static #deserialize(serialized, isReadOnly = false) { if (!SQL) { - SQL = $cpp("JSSQLStatement.cpp", "createJSSQLStatementConstructor"); + initializeSQL(); } return SQL.deserialize(serialized, isReadOnly); @@ -311,7 +376,7 @@ class Database { static setCustomSQLite(path) { if (!SQL) { - SQL = $cpp("JSSQLStatement.cpp", "createJSSQLStatementConstructor"); + initializeSQL(); } return SQL.setCustomSQLite(path); @@ -343,18 +408,20 @@ class Database { run(query, ...params) { if (params.length === 0) { - SQL.run(this.#handle, query); - return; + SQL.run(this.#handle, this.#internalFlags, internalFieldTuple, query); + return createChangesObject(); } var arg0 = params[0]; - return !isArray(arg0) && (!arg0 || typeof arg0 !== "object" || isTypedArray(arg0)) - ? SQL.run(this.#handle, query, params) - : SQL.run(this.#handle, query, ...params); + !isArray(arg0) && (!arg0 || typeof arg0 !== "object" || isTypedArray(arg0)) + ? SQL.run(this.#handle, this.#internalFlags, internalFieldTuple, query, params) + : SQL.run(this.#handle, this.#internalFlags, internalFieldTuple, query, ...params); + + return createChangesObject(); } prepare(query, params, flags) { - return new Statement(SQL.prepare(this.#handle, query, params, flags || 0)); + return new Statement(SQL.prepare(this.#handle, query, params, flags || 0, this.#internalFlags)); } static MAX_QUERY_CACHE_SIZE = 20; diff --git a/test/harness.ts b/test/harness.ts index 370f8834abaf35..be2f9cef49f702 100644 --- a/test/harness.ts +++ b/test/harness.ts @@ -1035,3 +1035,5 @@ export function rejectUnauthorizedScope(value: boolean) { }, }; } + +export const BREAKING_CHANGES_BUN_1_2 = false; diff --git a/test/js/bun/sqlite/sql-raw.test.js b/test/js/bun/sqlite/sql-raw.test.js deleted file mode 100644 index ccdf2ff5eb2f62..00000000000000 --- a/test/js/bun/sqlite/sql-raw.test.js +++ /dev/null @@ -1,55 +0,0 @@ -import { expect, it } from "bun:test"; -import { SQL } from "bun:internal-for-testing"; - -const dbPath = import.meta.dir + "/northwind.testdb"; - -it("works", () => { - const handle = SQL.open(dbPath); - - const stmt = SQL.prepare(handle, 'SELECT * FROM "Orders" WHERE OrderDate > datetime($date, "gmt")'); - expect(stmt.toString()).toBe(`SELECT * FROM "Orders" WHERE OrderDate > datetime(NULL, "gmt")`); - - expect( - Array.isArray( - stmt.all({ - // do the conversion this way so that this test runs in multiple timezones - $date: "1996-09-01T07:00:00.000Z", - }), - ), - ).toBe(true); - expect(stmt.toString()).toBe(`SELECT * FROM "Orders" WHERE OrderDate > datetime('1996-09-01T07:00:00.000Z', "gmt")`); - - var ran = stmt.run({ - $date: "1997-09-01T07:00:00.000Z", - }); - expect(Array.isArray(ran)).toBe(false); - expect(ran === undefined).toBe(true); - expect(stmt.toString()).toBe(`SELECT * FROM "Orders" WHERE OrderDate > datetime('1997-09-01T07:00:00.000Z', "gmt")`); - - expect( - Array.isArray( - stmt.get({ - $date: "1998-09-01T07:00:00.000Z", - }), - ), - ).toBe(false); - expect(stmt.toString()).toBe(`SELECT * FROM "Orders" WHERE OrderDate > datetime('1998-09-01T07:00:00.000Z', "gmt")`); - expect(stmt.paramsCount).toBe(1); - expect(stmt.columnsCount).toBe(14); - expect(stmt.columns.length).toBe(14); - stmt.finalize(); - SQL.close(handle); -}); - -it("SQL.run works", () => { - const handle = SQL.open(dbPath); - expect(typeof handle).toBe("number"); - - expect( - SQL.run(handle, 'SELECT * FROM "Orders" WHERE OrderDate > datetime($date, "gmt")', { - $date: new Date(1996, 8, 1).toISOString(), - }), - ).toBe(undefined); - - SQL.close(handle); -}); diff --git a/test/js/bun/sqlite/sql-timezone.test.js b/test/js/bun/sqlite/sql-timezone.test.js new file mode 100644 index 00000000000000..c39eafd1f2f1cd --- /dev/null +++ b/test/js/bun/sqlite/sql-timezone.test.js @@ -0,0 +1,73 @@ +import { expect, it } from "bun:test"; +import { Database } from "bun:sqlite"; + +const dbPath = import.meta.dir + "/northwind.testdb"; + +it("works with datetime", () => { + using db = Database.open(dbPath); + + using stmt = db.prepare('SELECT * FROM "Orders" WHERE OrderDate > datetime($date, "gmt")'); + expect(stmt.toString()).toBe(`SELECT * FROM "Orders" WHERE OrderDate > datetime(NULL, "gmt")`); + + expect( + stmt.all({ + // do the conversion this way so that this test runs in multiple timezones + $date: "1996-09-01T07:00:00.000Z", + }), + ).toHaveLength(0); + + expect( + Array.isArray( + stmt.all({ + // do the conversion this way so that this test runs in multiple timezones + $date: "1996-09-01T07:00:00.000Z", + }), + ), + ).toBe(true); + expect(stmt.toString()).toBe(`SELECT * FROM "Orders" WHERE OrderDate > datetime('1996-09-01T07:00:00.000Z', "gmt")`); + + var ran = stmt.run({ + $date: "1997-09-01T07:00:00.000Z", + }); + expect(ran).toEqual({ + changes: 0, + lastInsertRowid: 0, + }); + expect(stmt.toString()).toBe(`SELECT * FROM "Orders" WHERE OrderDate > datetime('1997-09-01T07:00:00.000Z', "gmt")`); + + expect( + stmt.get({ + $date: "1998-09-01T07:00:00.000Z", + }), + ).toBe(null); + expect(stmt.toString()).toBe(`SELECT * FROM "Orders" WHERE OrderDate > datetime('1998-09-01T07:00:00.000Z', "gmt")`); + expect(stmt.paramsCount).toBe(1); + expect(stmt.columnNames).toStrictEqual([ + "OrderID", + "CustomerID", + "EmployeeID", + "OrderDate", + "RequiredDate", + "ShippedDate", + "ShipVia", + "Freight", + "ShipName", + "ShipAddress", + "ShipCity", + "ShipRegion", + "ShipPostalCode", + "ShipCountry", + ]); +}); + +it("works with datetime string", () => { + using handle = new Database(dbPath); + expect( + handle.run('SELECT * FROM "Orders" WHERE OrderDate > datetime($date, "gmt")', { + $date: new Date(1996, 8, 1).toISOString(), + }), + ).toEqual({ + changes: 0, + lastInsertRowid: 0, + }); +}); diff --git a/test/js/bun/sqlite/sqlite.test.js b/test/js/bun/sqlite/sqlite.test.js index dac26cb6ab5dce..1516c38326c59b 100644 --- a/test/js/bun/sqlite/sqlite.test.js +++ b/test/js/bun/sqlite/sqlite.test.js @@ -1,13 +1,178 @@ import { expect, it, describe } from "bun:test"; import { Database, constants, SQLiteError } from "bun:sqlite"; import { existsSync, fstat, readdirSync, realpathSync, rmSync, writeFileSync } from "fs"; -import { spawnSync } from "bun"; -import { bunExe, isWindows, tempDirWithFiles } from "harness"; +import { $, spawnSync } from "bun"; +import { BREAKING_CHANGES_BUN_1_2, bunExe, isWindows, tempDirWithFiles } from "harness"; import { tmpdir } from "os"; import path from "path"; const tmpbase = tmpdir() + path.sep; +describe("as", () => { + it("should return an implementation of the class", () => { + const db = new Database(":memory:"); + db.exec("CREATE TABLE test (id INTEGER PRIMARY KEY, name TEXT)"); + db.run("INSERT INTO test (name) VALUES ('Hello')"); + db.run("INSERT INTO test (name) VALUES ('World')"); + + const q = db.query("SELECT * FROM test WHERE name = ?"); + class MyTest { + name; + + get isHello() { + return this.name === "Hello"; + } + } + + expect(q.get("Hello")).not.toBeInstanceOf(MyTest); + q.as(MyTest); + expect(q.get("Hello")).toBeInstanceOf(MyTest); + expect(q.get("Hello").isHello).toBe(true); + + const list = db.query("SELECT * FROM test"); + list.as(MyTest); + const all = list.all(); + expect(all[0]).toBeInstanceOf(MyTest); + expect(all[0].isHello).toBe(true); + expect(all[1]).toBeInstanceOf(MyTest); + expect(all[1].isHello).toBe(false); + }); + + it("should work with more complicated getters", () => { + class User { + rawBirthdate; + get birthdate() { + return new Date(this.rawBirthdate); + } + } + + const db = new Database(":memory:"); + db.exec("CREATE TABLE users (id INTEGER PRIMARY KEY, rawBirthdate TEXT)"); + db.run("INSERT INTO users (rawBirthdate) VALUES ('1995-12-19')"); + const query = db.query("SELECT * FROM users"); + query.as(User); + const user = query.get(); + expect(user.birthdate.getTime()).toBe(new Date("1995-12-19").getTime()); + }); + + it("validates the class", () => { + const db = new Database(":memory:"); + db.exec("CREATE TABLE test (id INTEGER PRIMARY KEY, name TEXT)"); + db.run("INSERT INTO test (name) VALUES ('Hello')"); + expect(() => db.query("SELECT * FROM test").as(null)).toThrow("Expected class to be a constructor or undefined"); + expect(() => db.query("SELECT * FROM test").as(() => {})).toThrow("Expected a constructor"); + function BadClass() {} + BadClass.prototype = 123; + expect(() => db.query("SELECT * FROM test").as(BadClass)).toThrow( + "Expected a constructor prototype to be an object", + ); + }); +}); + +describe("safeIntegers", () => { + it("should default to false", () => { + const db = Database.open(":memory:"); + db.exec("CREATE TABLE foo (id INTEGER PRIMARY KEY AUTOINCREMENT, age INTEGER NOT NULL)"); + db.run("INSERT INTO foo (age) VALUES (?)", BigInt(Number.MAX_SAFE_INTEGER) + 10n); + const query = db.query("SELECT * FROM foo"); + expect(query.all()).toEqual([{ id: 1, age: Number.MAX_SAFE_INTEGER + 10 }]); + query.safeIntegers(true); + expect(query.all()).toEqual([{ id: 1n, age: BigInt(Number.MAX_SAFE_INTEGER) + 10n }]); + }); + + it("should allow overwriting default", () => { + const db = Database.open(":memory:", { safeIntegers: true }); + db.exec("CREATE TABLE foo (id INTEGER PRIMARY KEY AUTOINCREMENT, age INTEGER NOT NULL)"); + db.run("INSERT INTO foo (age) VALUES (?)", BigInt(Number.MAX_SAFE_INTEGER) + 10n); + const query = db.query("SELECT * FROM foo"); + expect(query.all()).toEqual([{ id: 1n, age: BigInt(Number.MAX_SAFE_INTEGER) + 10n }]); + query.safeIntegers(false); + query.as; + expect(query.all()).toEqual([{ id: 1, age: Number.MAX_SAFE_INTEGER + 10 }]); + }); + + it("should throw range error if value is out of range", () => { + 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)"); + + expect(() => query.run({ $value: BigInt(Number.MAX_SAFE_INTEGER) ** 2n })).toThrow(RangeError); + query.safeIntegers(false); + expect(() => query.run({ $value: BigInt(Number.MAX_SAFE_INTEGER) ** 2n })).not.toThrow(RangeError); + }); +}); + +{ + const strictInputs = [ + { name: "myname", age: 42 }, + { age: 42, name: "myname" }, + ["myname", 42], + { 0: "myname", 1: 42 }, + { 1: "myname", 0: 42 }, + ]; + const queries = ["$name, $age", "$name, $age", "?, ?", "?1, ?2", "?2, ?1"]; + const uglyInputs = [ + { $name: "myname", $age: 42 }, + { $age: 42, $name: "myname" }, + ["myname", 42], + { "?1": "myname", "?2": 42 }, + { "?2": "myname", "?1": 42 }, + ]; + + for (const strict of [true, false]) { + describe(strict ? "strict" : "default", () => { + const inputs = strict ? strictInputs : uglyInputs; + for (let i = 0; i < strictInputs.length; i++) { + const input = inputs[i]; + const query = queries[i]; + it(`${JSON.stringify(input)} -> ${query}`, () => { + const db = Database.open(":memory:", { strict }); + db.exec( + "CREATE TABLE cats (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT UNIQUE NOT NULL, age INTEGER NOT NULL)", + ); + const { changes, lastInsertRowid } = db.run(`INSERT INTO cats (name, age) VALUES (${query})`, input); + expect(changes).toBe(1); + expect(lastInsertRowid).toBe(1); + + expect(db.query("SELECT * FROM cats").all()).toStrictEqual([{ id: 1, name: "myname", age: 42 }]); + expect(db.query(`SELECT * FROM cats WHERE (name, age) = (${query})`).all(input)).toStrictEqual([ + { id: 1, name: "myname", age: 42 }, + ]); + expect(db.query(`SELECT * FROM cats WHERE (name, age) = (${query})`).get(input)).toStrictEqual({ + id: 1, + name: "myname", + age: 42, + }); + expect(db.query(`SELECT * FROM cats WHERE (name, age) = (${query})`).values(input)).toStrictEqual([ + [1, "myname", 42], + ]); + }); + } + + if (strict) { + describe("throws missing parameter error in", () => { + for (let method of ["all", "get", "values", "run"]) { + it(`${method}()`, () => { + const db = Database.open(":memory:", { strict: true }); + + db.exec("CREATE TABLE cats (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT, age INTEGER)"); + + expect(() => { + const query = db.query("INSERT INTO cats (name, age) VALUES (@name, @age)"); + + query[method]({ + "name": "Joey", + }); + }).toThrow('Missing parameter "age"'); + }); + } + }); + } + }); + } +} + var encode = text => new TextEncoder().encode(text); // Use different numbers of columns to ensure we crash if using initializeIndex() on a large array can cause bugs. @@ -182,7 +347,10 @@ it("creates", () => { }, ]), ); - expect(stmt2.run()).toBe(undefined); + expect(stmt2.run()).toStrictEqual({ + changes: 0, + lastInsertRowid: 3, + }); // not necessary to run but it's a good practice stmt2.finalize(); @@ -399,7 +567,7 @@ it("db.query()", () => { try { db.query("SELECT * FROM test where (name = ? OR name = ?)").all("Hello"); } catch (e) { - expect(e.message).toBe("Expected 2 values, got 1"); + expect(e.message).toBe("SQLite query expected 2 values, received 1"); } // named parameters @@ -437,6 +605,75 @@ it("db.query()", () => { db.close(); }); +it("db.run()", () => { + const db = Database.open(":memory:"); + + db.exec("CREATE TABLE cats (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT UNIQUE NOT NULL, age INTEGER NOT NULL)"); + + const insert = db.query("INSERT INTO cats (name, age) VALUES (@name, @age) RETURNING name").all({ + "@name": "Joey", + "@age": 2, + }); +}); + +for (let strict of [false, true]) { + it(`strict: ${strict}`, () => { + const db = Database.open(":memory:", { strict }); + + db.exec("CREATE TABLE cats (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT UNIQUE, age INTEGER)"); + + const result = db.query("INSERT INTO cats (name, age) VALUES (@name, @age) RETURNING name").all({ + [(!strict ? "@" : "") + "name"]: "Joey", + [(!strict ? "@" : "") + "age"]: 2, + }); + expect(result).toStrictEqual([{ name: "Joey" }]); + }); +} +it("strict: true", () => { + const db = Database.open(":memory:", { strict: true }); + + db.exec("CREATE TABLE cats (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT UNIQUE NOT NULL, age INTEGER NOT NULL)"); + + const insert = db.query("INSERT INTO cats (name, age) VALUES (@name, @age) RETURNING name").all({ + "name": "Joey", + "age": 2, + }); +}); + +describe("does not throw missing parameter error in", () => { + for (let method of ["all", "get", "values", "run"]) { + it(`${method}()`, () => { + it(`${method}()`, () => { + const db = Database.open(":memory:"); + + db.exec("CREATE TABLE cats (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT, age INTEGER)"); + + expect(() => { + const query = db.query("INSERT INTO cats (name, age) VALUES (@name, @age) RETURNING name"); + const result = query[method]({ + "@name": "Joey", + }); + switch (method) { + case "all": + expect(result).toHaveLength(1); + expect(result[0]).toStrictEqual({ name: "Joey" }); + break; + case "get": + expect(result).toStrictEqual({ name: "Joey" }); + break; + case "values": + expect(result).toStrictEqual([["Joey"]]); + break; + case "run": + expect(result).toEqual({ changes: 1, lastInsertRowid: 1 }); + break; + } + }).not.toThrow(); + }); + }); + } +}); + it("db.transaction()", () => { const db = Database.open(":memory:"); @@ -493,46 +730,46 @@ it("inlineCapacity #987", async () => { const db = new Database(path); - const query = `SELECT - media.mid, - UPPER(media.name) as name, - media.url, - media.duration, - time(media.duration, 'unixepoch') AS durationStr, - sum(totalDurations) AS totalDurations, - sum(logs.views) AS views, - total.venues, - total.devices, + const query = `SELECT + media.mid, + UPPER(media.name) as name, + media.url, + media.duration, + time(media.duration, 'unixepoch') AS durationStr, + sum(totalDurations) AS totalDurations, + sum(logs.views) AS views, + total.venues, + total.devices, SUM(CASE WHEN day = '01' THEN logs.views ELSE 0 END) as 'vi01', SUM(CASE WHEN day = '02' THEN logs.views ELSE 0 END) as 'vi02', SUM(CASE WHEN day = '03' THEN logs.views ELSE 0 END) as 'vi03', SUM(CASE WHEN day = '04' THEN logs.views ELSE 0 END) as 'vi04', SUM(CASE WHEN day = '05' THEN logs.views ELSE 0 END) as 'vi05', SUM(CASE WHEN day = '06' THEN logs.views ELSE 0 END) as 'vi06', SUM(CASE WHEN day = '07' THEN logs.views ELSE 0 END) as 'vi07', SUM(CASE WHEN day = '08' THEN logs.views ELSE 0 END) as 'vi08', SUM(CASE WHEN day = '09' THEN logs.views ELSE 0 END) as 'vi09', SUM(CASE WHEN day = '10' THEN logs.views ELSE 0 END) as 'vi10', SUM(CASE WHEN day = '11' THEN logs.views ELSE 0 END) as 'vi11', SUM(CASE WHEN day = '12' THEN logs.views ELSE 0 END) as 'vi12', SUM(CASE WHEN day = '13' THEN logs.views ELSE 0 END) as 'vi13', SUM(CASE WHEN day = '14' THEN logs.views ELSE 0 END) as 'vi14', SUM(CASE WHEN day = '15' THEN logs.views ELSE 0 END) as 'vi15', SUM(CASE WHEN day = '16' THEN logs.views ELSE 0 END) as 'vi16', SUM(CASE WHEN day = '17' THEN logs.views ELSE 0 END) as 'vi17', SUM(CASE WHEN day = '18' THEN logs.views ELSE 0 END) as 'vi18', SUM(CASE WHEN day = '19' THEN logs.views ELSE 0 END) as 'vi19', SUM(CASE WHEN day = '20' THEN logs.views ELSE 0 END) as 'vi20', SUM(CASE WHEN day = '21' THEN logs.views ELSE 0 END) as 'vi21', SUM(CASE WHEN day = '22' THEN logs.views ELSE 0 END) as 'vi22', SUM(CASE WHEN day = '23' THEN logs.views ELSE 0 END) as 'vi23', SUM(CASE WHEN day = '24' THEN logs.views ELSE 0 END) as 'vi24', SUM(CASE WHEN day = '25' THEN logs.views ELSE 0 END) as 'vi25', SUM(CASE WHEN day = '26' THEN logs.views ELSE 0 END) as 'vi26', SUM(CASE WHEN day = '27' THEN logs.views ELSE 0 END) as 'vi27', SUM(CASE WHEN day = '28' THEN logs.views ELSE 0 END) as 'vi28', SUM(CASE WHEN day = '29' THEN logs.views ELSE 0 END) as 'vi29', SUM(CASE WHEN day = '30' THEN logs.views ELSE 0 END) as 'vi30', MAX(CASE WHEN day = '01' THEN logs.venues ELSE 0 END) as 've01', MAX(CASE WHEN day = '02' THEN logs.venues ELSE 0 END) as 've02', MAX(CASE WHEN day = '03' THEN logs.venues ELSE 0 END) as 've03', MAX(CASE WHEN day = '04' THEN logs.venues ELSE 0 END) as 've04', MAX(CASE WHEN day = '05' THEN logs.venues ELSE 0 END) as 've05', MAX(CASE WHEN day = '06' THEN logs.venues ELSE 0 END) as 've06', MAX(CASE WHEN day = '07' THEN logs.venues ELSE 0 END) as 've07', MAX(CASE WHEN day = '08' THEN logs.venues ELSE 0 END) as 've08', MAX(CASE WHEN day = '09' THEN logs.venues ELSE 0 END) as 've09', MAX(CASE WHEN day = '10' THEN logs.venues ELSE 0 END) as 've10', MAX(CASE WHEN day = '11' THEN logs.venues ELSE 0 END) as 've11', MAX(CASE WHEN day = '12' THEN logs.venues ELSE 0 END) as 've12', MAX(CASE WHEN day = '13' THEN logs.venues ELSE 0 END) as 've13', MAX(CASE WHEN day = '14' THEN logs.venues ELSE 0 END) as 've14', MAX(CASE WHEN day = '15' THEN logs.venues ELSE 0 END) as 've15', MAX(CASE WHEN day = '16' THEN logs.venues ELSE 0 END) as 've16', MAX(CASE WHEN day = '17' THEN logs.venues ELSE 0 END) as 've17', MAX(CASE WHEN day = '18' THEN logs.venues ELSE 0 END) as 've18', MAX(CASE WHEN day = '19' THEN logs.venues ELSE 0 END) as 've19', MAX(CASE WHEN day = '20' THEN logs.venues ELSE 0 END) as 've20', MAX(CASE WHEN day = '21' THEN logs.venues ELSE 0 END) as 've21', MAX(CASE WHEN day = '22' THEN logs.venues ELSE 0 END) as 've22', MAX(CASE WHEN day = '23' THEN logs.venues ELSE 0 END) as 've23', MAX(CASE WHEN day = '24' THEN logs.venues ELSE 0 END) as 've24', MAX(CASE WHEN day = '25' THEN logs.venues ELSE 0 END) as 've25', MAX(CASE WHEN day = '26' THEN logs.venues ELSE 0 END) as 've26', MAX(CASE WHEN day = '27' THEN logs.venues ELSE 0 END) as 've27', MAX(CASE WHEN day = '28' THEN logs.venues ELSE 0 END) as 've28', MAX(CASE WHEN day = '29' THEN logs.venues ELSE 0 END) as 've29', MAX(CASE WHEN day = '30' THEN logs.venues ELSE 0 END) as 've30', MAX(CASE WHEN day = '01' THEN logs.devices ELSE 0 END) as 'de01', MAX(CASE WHEN day = '02' THEN logs.devices ELSE 0 END) as 'de02', MAX(CASE WHEN day = '03' THEN logs.devices ELSE 0 END) as 'de03', MAX(CASE WHEN day = '04' THEN logs.devices ELSE 0 END) as 'de04', MAX(CASE WHEN day = '05' THEN logs.devices ELSE 0 END) as 'de05', MAX(CASE WHEN day = '06' THEN logs.devices ELSE 0 END) as 'de06', MAX(CASE WHEN day = '07' THEN logs.devices ELSE 0 END) as 'de07', MAX(CASE WHEN day = '08' THEN logs.devices ELSE 0 END) as 'de08', MAX(CASE WHEN day = '09' THEN logs.devices ELSE 0 END) as 'de09', MAX(CASE WHEN day = '10' THEN logs.devices ELSE 0 END) as 'de10', MAX(CASE WHEN day = '11' THEN logs.devices ELSE 0 END) as 'de11', MAX(CASE WHEN day = '12' THEN logs.devices ELSE 0 END) as 'de12', MAX(CASE WHEN day = '13' THEN logs.devices ELSE 0 END) as 'de13', MAX(CASE WHEN day = '14' THEN logs.devices ELSE 0 END) as 'de14', MAX(CASE WHEN day = '15' THEN logs.devices ELSE 0 END) as 'de15', MAX(CASE WHEN day = '16' THEN logs.devices ELSE 0 END) as 'de16', MAX(CASE WHEN day = '17' THEN logs.devices ELSE 0 END) as 'de17', MAX(CASE WHEN day = '18' THEN logs.devices ELSE 0 END) as 'de18', MAX(CASE WHEN day = '19' THEN logs.devices ELSE 0 END) as 'de19', MAX(CASE WHEN day = '20' THEN logs.devices ELSE 0 END) as 'de20', MAX(CASE WHEN day = '21' THEN logs.devices ELSE 0 END) as 'de21', MAX(CASE WHEN day = '22' THEN logs.devices ELSE 0 END) as 'de22', MAX(CASE WHEN day = '23' THEN logs.devices ELSE 0 END) as 'de23', MAX(CASE WHEN day = '24' THEN logs.devices ELSE 0 END) as 'de24', MAX(CASE WHEN day = '25' THEN logs.devices ELSE 0 END) as 'de25', MAX(CASE WHEN day = '26' THEN logs.devices ELSE 0 END) as 'de26', MAX(CASE WHEN day = '27' THEN logs.devices ELSE 0 END) as 'de27', MAX(CASE WHEN day = '28' THEN logs.devices ELSE 0 END) as 'de28', MAX(CASE WHEN day = '29' THEN logs.devices ELSE 0 END) as 'de29', MAX(CASE WHEN day = '30' THEN logs.devices ELSE 0 END) as 'de30' - FROM + FROM ( - SELECT - logs.mid, - sum(logs.duration) AS totalDurations, - strftime ('%d', START, 'unixepoch', 'localtime') AS day, - count(*) AS views, - count(DISTINCT did) AS devices, - count(DISTINCT vid) AS venues - FROM - logs + SELECT + logs.mid, + sum(logs.duration) AS totalDurations, + strftime ('%d', START, 'unixepoch', 'localtime') AS day, + count(*) AS views, + count(DISTINCT did) AS devices, + count(DISTINCT vid) AS venues + FROM + logs WHERE strftime('%m-%Y', start, 'unixepoch', 'localtime')='06-2022' - GROUP BY - day, + GROUP BY + day, logs.mid - ) logs - INNER JOIN media ON media.id = logs.mid + ) logs + INNER JOIN media ON media.id = logs.mid INNER JOIN ( - SELECT - mid, - count(DISTINCT vid) as venues, - count(DISTINCT did) as devices - FROM - logs + SELECT + mid, + count(DISTINCT vid) as venues, + count(DISTINCT did) as devices + FROM + logs WHERE strftime('%m-%Y', start, 'unixepoch', 'localtime')='06-2022' - GROUP by + GROUP by mid - ) total ON logs.mid = total.mid - ORDER BY + ) total ON logs.mid = total.mid + ORDER BY name`; expect(Object.keys(db.query(query).all()[0]).length).toBe(99); @@ -732,7 +969,7 @@ it("multiple statements with a schema change", () => { const db = new Database(":memory:"); db.run( ` - CREATE TABLE foo (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT); + CREATE TABLE foo (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT); CREATE TABLE bar (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT); INSERT INTO foo (name) VALUES ('foo'); @@ -968,6 +1205,7 @@ it("can continue to use existing statements after database has been GC'd", async }); function leakTheStatement() { const db = new Database(":memory:"); + console.log("---"); 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"); @@ -976,6 +1214,7 @@ it("can continue to use existing statements after database has been GC'd", async } const stmt = leakTheStatement(); + Bun.gc(true); await Bun.sleep(1); Bun.gc(true);