diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..5d68ead --- /dev/null +++ b/.prettierrc @@ -0,0 +1,5 @@ +{ + "semi": true, + "printWidth": 130, + "tabWidth": 4 +} diff --git a/package-lock.json b/package-lock.json index cfd0679..f090f5e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,6 +17,7 @@ "@types/better-sqlite3": "^7.6.3", "@types/jest": "^29.2.5", "@types/node": "^18.15.5", + "bun-types": "^0.8.1", "jest": "^29.3.1", "ts-jest": "^29.0.5", "typedoc": "^0.23.28", @@ -1466,6 +1467,12 @@ "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", "dev": true }, + "node_modules/bun-types": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/bun-types/-/bun-types-0.8.1.tgz", + "integrity": "sha512-VuCBox66P/3a8gVOffLCWIS6vdpXq4y3eJuF3VnsyC5HpykmIjkcr5wYDn22qQdeTUmOfCcBy1SZmtrZCeUr3A==", + "dev": true + }, "node_modules/callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", diff --git a/package.json b/package.json index f81158f..400c694 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,6 @@ "Jan Plhak " ], "license": "MIT", - "type": "module", "main": "lib-cjs/node.js", "types": "lib-esm/node.d.ts", @@ -33,6 +32,7 @@ "deno": "./lib-esm/web.js", "edge-light": "./lib-esm/web.js", "netlify": "./lib-esm/web.js", + "bun": "./lib-esm/bun.js", "node": "./lib-esm/node.js", "default": "./lib-esm/node.js" }, @@ -62,22 +62,47 @@ "types": "./lib-esm/web.d.ts", "import": "./lib-esm/web.js", "require": "./lib-cjs/web.js" + }, + "./bun": { + "types": "./lib-esm/bun.d.ts", + "import": "./lib-esm/bun.js", + "require": "./lib-cjs/bun.js" + }, + "./bun-sqlite": { + "types": "./lib-esm/bun_sqlite.d.ts", + "import": "./lib-esm/bun_sqlite.js", + "require": "./lib-cjs/bun_sqlite.js" } }, "typesVersions": { "*": { - ".": ["./lib-esm/node.d.ts"], - "http": ["./lib-esm/http.d.ts"], - "hrana": ["./lib-esm/hrana.d.ts"], - "sqlite3": ["./lib-esm/sqlite3.d.ts"], - "web": ["./lib-esm/web.d.ts"] + ".": [ + "./lib-esm/node.d.ts" + ], + "http": [ + "./lib-esm/http.d.ts" + ], + "hrana": [ + "./lib-esm/hrana.d.ts" + ], + "sqlite3": [ + "./lib-esm/sqlite3.d.ts" + ], + "web": [ + "./lib-esm/web.d.ts" + ], + "bun": [ + "./lib-esm/bun.d.ts" + ], + "bun-sqlite": [ + "./lib-esm/bun_sqlite.d.ts" + ] } }, "files": [ "lib-cjs/**", "lib-esm/**" ], - "scripts": { "prepublishOnly": "npm run build", "prebuild": "rm -rf ./lib-cjs ./lib-esm", @@ -86,10 +111,10 @@ "build:esm": "tsc -p tsconfig.build-esm.json", "postbuild": "cp package-cjs.json ./lib-cjs/package.json", "test": "jest --runInBand", + "test:bun": "bun test bun", "typecheck": "tsc --noEmit", "typedoc": "rm -rf ./docs && typedoc" }, - "dependencies": { "@libsql/hrana-client": "^0.5.0-pre.2", "better-sqlite3": "^8.0.1", @@ -99,6 +124,7 @@ "@types/better-sqlite3": "^7.6.3", "@types/jest": "^29.2.5", "@types/node": "^18.15.5", + "bun-types": "^0.8.1", "jest": "^29.3.1", "ts-jest": "^29.0.5", "typedoc": "^0.23.28", diff --git a/src/__tests__/client.test.ts b/src/__tests__/client.test.ts index d73d793..0383f92 100644 --- a/src/__tests__/client.test.ts +++ b/src/__tests__/client.test.ts @@ -1,6 +1,4 @@ -import console from "node:console"; import { expect } from "@jest/globals"; -import type { MatcherFunction } from "expect"; import type { Request, Response } from "@libsql/hrana-client"; import { fetch } from "@libsql/hrana-client"; @@ -449,7 +447,7 @@ describe("batch()", () => { ], "write"); const n = 100; - const promises = []; + const promises: Promise[] = []; for (let i = 0; i < n; ++i) { const ii = i; promises.push((async () => { @@ -495,7 +493,7 @@ describe("batch()", () => { })); test("batch with a lot of different statements", withClient(async (c) => { - const stmts = []; + const stmts: string[] = []; for (let i = 0; i < 1000; ++i) { stmts.push(`SELECT ${i}`); } @@ -509,7 +507,7 @@ describe("batch()", () => { const n = 20; const m = 200; - const stmts = []; + const stmts: libsql.InStatement[] = []; for (let i = 0; i < n; ++i) { for (let j = 0; j < m; ++j) { stmts.push({sql: `SELECT ?, ${j}`, args: [i]}); diff --git a/src/__tests__/helpers.ts b/src/__tests__/helpers.ts index c48d019..f25f568 100644 --- a/src/__tests__/helpers.ts +++ b/src/__tests__/helpers.ts @@ -10,7 +10,7 @@ const toBeLibsqlError: MatcherFunction<[code?: string, message?: RegExp]> = && (messageRe === undefined || actual.message.match(messageRe) !== null); const message = (): string => { - const parts = []; + const parts: string[] = []; parts.push("expected "); parts.push(this.utils.printReceived(actual)); parts.push(pass ? " not to be " : " to be "); diff --git a/src/bun.ts b/src/bun.ts new file mode 100644 index 0000000..cd5f151 --- /dev/null +++ b/src/bun.ts @@ -0,0 +1,38 @@ +import type { Config, Client } from "./api.js"; +import { LibsqlError } from "./api.js"; +import type { ExpandedConfig } from "./config.js"; +import { expandConfig } from "./config.js"; +import { supportedUrlLink } from "./util.js"; + +import { _createClient as _createWsClient } from "./ws.js"; +import { _createClient as _createHttpClient } from "./http.js"; +import { _createClient as _createBunSqliteClient } from "./bun_sqlite.js"; + +export * from "./api.js"; + +export function createClient(config: Config): Client { + return _createClient(expandConfig(config, true)); +} + +export const isBun = () => { + if ((globalThis as any).Bun || (globalThis as any).process?.versions?.bun) return; + throw new LibsqlError("Bun is not available", "BUN_NOT_AVAILABLE"); +}; + +/** @private */ +export function _createClient(config: ExpandedConfig): Client { + isBun(); + if (config.scheme === "ws" || config.scheme === "wss") { + return _createWsClient(config); + } else if (config.scheme === "http" || config.scheme === "https") { + return _createHttpClient(config); + } else if (config.scheme === "file") { + return _createBunSqliteClient(config); + } else { + throw new LibsqlError( + 'The Bun client supports "file", "libsql:", "wss:", "ws:", "https:" and "http:" URLs, ' + + `got ${JSON.stringify(config.scheme + ":")}. For more information, please read ${supportedUrlLink}`, + "URL_SCHEME_NOT_SUPPORTED" + ); + } +} diff --git a/src/bun_sqlite.ts b/src/bun_sqlite.ts new file mode 100644 index 0000000..8489893 --- /dev/null +++ b/src/bun_sqlite.ts @@ -0,0 +1,288 @@ +// @ts-ignore bun:sqlite is not typed when building +import { Database, SQLQueryBindings } from "bun:sqlite"; +import type { + Config, + IntMode, + Client, + Transaction, + TransactionMode, + ResultSet, + Row, + Value, + InValue, + InStatement, +} from "./api.js"; +import { LibsqlError } from "./api.js"; +import { expandConfig, type ExpandedConfig } from "./config.js"; +import { + transactionModeToBegin, + ResultSetImpl, + validateFileConfig, + parseStatement, + minSafeBigint, + maxSafeBigint, + minInteger, + maxInteger, +} from "./util.js"; +import { isBun } from "./bun.js"; + +export * from "./api.js"; + +type ConstructorParameters = T extends new (...args: infer P) => any ? P : never; +type DatabaseOptions = ConstructorParameters[1]; + +export function createClient(_config: Config): Client { + isBun(); + const config = validateFileConfig(expandConfig(_config, true)); + //@note bun bigint handling https://github.com/oven-sh/bun/issues/1536 + if (config.intMode !== "number") throw intModeNotImplemented(config.intMode); + return _createClient(config); +} + +/** @private */ +export function _createClient(config: ExpandedConfig): Client { + const path = config.path; + const options = undefined; //@todo implement options + const db = new Database(path); + try { + executeStmt(db, "SELECT 1 AS checkThatTheDatabaseCanBeOpened", config.intMode); + } finally { + db.close(); + } + + return new BunSqliteClient(path, options, config.intMode); +} + +export class BunSqliteClient implements Client { + #path: string; + #options: DatabaseOptions; + #intMode: IntMode; + closed: boolean; + protocol: "file"; + + /** @private */ + constructor(path: string, options: DatabaseOptions, intMode: IntMode) { + this.#path = path; + this.#options = options; + this.#intMode = intMode; + this.closed = false; + this.protocol = "file"; + } + + async execute(stmt: InStatement): Promise { + this.#checkNotClosed(); + const db = new Database(this.#path, this.#options); + try { + return executeStmt(db, stmt, this.#intMode); + } finally { + db.close(); + } + } + + async batch(stmts: Array, mode: TransactionMode = "deferred"): Promise> { + this.#checkNotClosed(); + const db = new Database(this.#path, this.#options); + try { + executeStmt(db, transactionModeToBegin(mode), this.#intMode); + const resultSets = stmts.map((stmt) => { + if (!db.inTransaction) { + throw new LibsqlError("The transaction has been rolled back", "TRANSACTION_CLOSED"); + } + return executeStmt(db, stmt, this.#intMode); + }); + executeStmt(db, "COMMIT", this.#intMode); + return resultSets; + } finally { + db.close(); + } + } + + async transaction(mode: TransactionMode = "write"): Promise { + this.#checkNotClosed(); + const db = new Database(this.#path, this.#options); + try { + executeStmt(db, transactionModeToBegin(mode), this.#intMode); + return new BunSqliteTransaction(db, this.#intMode); + } catch (e) { + db.close(); + throw e; + } + } + + async executeMultiple(sql: string): Promise { + throw executeMultipleNotImplemented(); + } + + close(): void { + this.closed = true; + } + + #checkNotClosed(): void { + if (this.closed) { + throw new LibsqlError("The client is closed", "CLIENT_CLOSED"); + } + } +} + +export class BunSqliteTransaction implements Transaction { + #database: Database; + #intMode: IntMode; + #closed: boolean; + + /** @private */ + constructor(database: Database, intMode: IntMode) { + this.#database = database; + this.#intMode = intMode; + this.#closed = false; + } + + async execute(stmt: InStatement): Promise { + this.#checkNotClosed(); + return executeStmt(this.#database, stmt, this.#intMode); + } + + async batch(stmts: Array): Promise> { + return stmts.map((stmt) => { + this.#checkNotClosed(); + return executeStmt(this.#database, stmt, this.#intMode); + }); + } + + async executeMultiple(sql: string): Promise { + throw executeMultipleNotImplemented(); + } + + async rollback(): Promise { + if (this.#closed) { + return; + } + this.#checkNotClosed(); + executeStmt(this.#database, "ROLLBACK", this.#intMode); + this.close(); + } + + async commit(): Promise { + this.#checkNotClosed(); + executeStmt(this.#database, "COMMIT", this.#intMode); + this.close(); + } + + close(): void { + this.#database.close(); + this.#closed = true; + } + + get closed(): boolean { + return this.#closed; + } + + #checkNotClosed(): void { + if (this.#closed || !this.#database.inTransaction) { + throw new LibsqlError("The transaction is closed", "TRANSACTION_CLOSED"); + } + } +} + +function executeStmt(db: Database, stmt: InStatement, intMode: IntMode): ResultSet { + const { sql, args } = parseStatement(stmt, valueToSql); + try { + const sqlStmt = db.prepare(sql); + const data = sqlStmt.all(args as SQLQueryBindings) as Record[]; + sqlStmt.finalize(); + if (Array.isArray(data) && data.length > 0) { + const columns = sqlStmt.columnNames; + const rows = convertSqlResultToRows(data, intMode); + //@note info about the last insert rowid is not available with bun:sqlite + const rowsAffected = 0; + const lastInsertRowid = undefined; + return new ResultSetImpl(columns, rows, rowsAffected, lastInsertRowid); + } else { + const rowsAffected = typeof data === "number" ? data : 0; + const lastInsertRowid = BigInt(0); + return new ResultSetImpl([], [], rowsAffected, lastInsertRowid); + } + } catch (e) { + throw mapSqliteError(e); + } +} + +function convertSqlResultToRows(results: Record[], intMode: IntMode): Row[] { + return results.map((result) => { + const entries = Object.entries(result); + const row: Partial = {}; + + //We use Object.defineProperty to make the properties non-enumerable + entries.forEach(([name, v], index) => { + const value = valueFromSql(v, intMode); + Object.defineProperty(row, name, { value, enumerable: true, configurable: true }); + Object.defineProperty(row, index, { value, configurable: true }); + }); + + Object.defineProperty(row, "length", { value: entries.length, configurable: true }); + + return row as Row; + }); +} + +function valueFromSql(sqlValue: unknown, intMode: IntMode): Value { + // https://github.com/oven-sh/bun/issues/1536 + if (typeof sqlValue === "bigint") { + if (intMode === "number") { + if (sqlValue < minSafeBigint || sqlValue > maxSafeBigint) { + throw new RangeError("Received integer which cannot be safely represented as a JavaScript number"); + } + return Number(sqlValue); + } else if (intMode === "bigint") { + return sqlValue; + } else if (intMode === "string") { + return String(sqlValue); + } else { + throw new Error("Invalid value for IntMode"); + } + } else if (ArrayBuffer.isView(sqlValue)) { + return sqlValue.buffer as Value; + } + return sqlValue as Value; +} + +function valueToSql(value: InValue) { + if (typeof value === "number") { + if (!Number.isFinite(value)) { + throw new RangeError("Only finite numbers (not Infinity or NaN) can be passed as arguments"); + } + return value; + } else if (typeof value === "bigint") { + if (value < minInteger || value > maxInteger) { + throw new RangeError("bigint is too large to be represented as a 64-bit integer and passed as argument"); + } + return value; + } else if (typeof value === "boolean") { + return value; + } else if (value instanceof ArrayBuffer) { + return Buffer.from(value); + } else if (value instanceof Date) { + return value.valueOf(); + } else if (value === undefined) { + throw new TypeError("undefined cannot be passed as argument to the database"); + } else { + return value; + } +} + +const BUN_SQLITE_ERROR = "BUN_SQLITE ERROR" as const; + +function mapSqliteError(e: unknown): unknown { + if (e instanceof RangeError) { + return e; + } + if (e instanceof Error) { + return new LibsqlError(e.message, BUN_SQLITE_ERROR, e); + } + return e; +} + +const executeMultipleNotImplemented = () => + new LibsqlError("bun:sqlite doesn't support executeMultiple. Use batch instead.", BUN_SQLITE_ERROR); + +const intModeNotImplemented = (mode: string) => + new LibsqlError(`"${mode}" intMode is not supported by bun:sqlite. Only intMode "number" is supported.`, BUN_SQLITE_ERROR); diff --git a/src/bun_tests/bun.client.test.ts b/src/bun_tests/bun.client.test.ts new file mode 100644 index 0000000..fe7ba24 --- /dev/null +++ b/src/bun_tests/bun.client.test.ts @@ -0,0 +1,1038 @@ +import { describe, test, expect } from "bun:test"; + +import type { Request, Response } from "@libsql/hrana-client"; +import { fetch } from "@libsql/hrana-client"; + +import type * as libsql from "../bun"; +import { createClient } from "../bun"; +import { expectBunSqliteError, expectLibSqlError, withPattern } from "./bun.helpers"; + +const config = { + url: process.env.URL ?? "file:///tmp/test.db" ?? "ws://localhost:8080", + authToken: process.env.AUTH_TOKEN, +}; + +function withClient(f: (c: libsql.Client) => Promise, extraConfig?: Partial): () => Promise { + return async () => { + const c = createClient({ ...config, ...extraConfig }); + try { + await f(c); + } finally { + c.close(); + } + }; +} + +describe("createClient()", () => { + test("URL scheme not supported", () => { + expectLibSqlError(() => createClient({ url: "ftp://localhost" }), withPattern("URL_SCHEME_NOT_SUPPORTED", /"ftp:"/)); + }); + + test("URL param not supported", () => { + expectLibSqlError(() => createClient({ url: "ws://localhost?foo=bar" }), withPattern("URL_PARAM_NOT_SUPPORTED", /"foo"/)); + }); + + test("URL scheme incompatible with ?tls", () => { + const urls = ["ws://localhost?tls=1", "wss://localhost?tls=0", "http://localhost?tls=1", "https://localhost?tls=0"]; + for (const url of urls) { + expectLibSqlError(() => createClient({ url }), withPattern("URL_INVALID", /TLS/)); + } + }); + + test("missing port in libsql URL with tls=0", () => { + expectLibSqlError(() => createClient({ url: "libsql://localhost?tls=0" }), withPattern("URL_INVALID", /port/)); + }); + + test("invalid value of tls query param", () => { + expectLibSqlError(() => createClient({ url: "libsql://localhost?tls=yes" }), withPattern("URL_INVALID", /"tls".*"yes"/)); + }); + + test("passing URL instead of config object", () => { + // @ts-expect-error + expect(() => createClient("ws://localhost").toThrow(/as object, got string/)); + }); + + test("invalid value for `intMode`", () => { + // @ts-expect-error + expect(() => createClient({ ...config, intMode: "foo" }).toThrow(/"foo"/)); + }); +}); + +describe("execute()", () => { + test( + "query a single value", + withClient(async (c) => { + const rs = await c.execute("SELECT 42"); + expect(rs.columns.length).toStrictEqual(1); + expect(rs.rows.length).toStrictEqual(1); + expect(rs.rows[0].length).toStrictEqual(1); + expect(rs.rows[0][0]).toStrictEqual(42); + }) + ); + + test( + "query a single row", + withClient(async (c) => { + const rs = await c.execute("SELECT 1 AS one, 'two' AS two, 0.5 AS three"); + expect(rs.columns).toStrictEqual(["one", "two", "three"]); + expect(rs.rows.length).toStrictEqual(1); + + const r = rs.rows[0]; + expect(r.length).toStrictEqual(3); + expect(Array.from(r)).toStrictEqual([1, "two", 0.5]); + expect(Object.entries(r)).toStrictEqual([ + ["one", 1], + ["two", "two"], + ["three", 0.5], + ]); + }) + ); + + test( + "query multiple rows", + withClient(async (c) => { + const rs = await c.execute("VALUES (1, 'one'), (2, 'two'), (3, 'three')"); + expect(rs.columns.length).toStrictEqual(2); + expect(rs.rows.length).toStrictEqual(3); + + expect(Array.from(rs.rows[0])).toStrictEqual([1, "one"]); + expect(Array.from(rs.rows[1])).toStrictEqual([2, "two"]); + expect(Array.from(rs.rows[2])).toStrictEqual([3, "three"]); + }) + ); + + test( + "statement that produces error", + withClient(async (c) => { + await expectBunSqliteError(() => c.execute("SELECT foobar")); + }) + ); + + test( + "rowsAffected with INSERT", + withClient(async (c) => { + await c.batch(["DROP TABLE IF EXISTS t", "CREATE TABLE t (a)"], "write"); + const rs = await c.execute("INSERT INTO t VALUES (1), (2)"); + expect(rs.rowsAffected).toStrictEqual(2); + }) + ); + + test( + "rowsAffected with DELETE", + withClient(async (c) => { + await c.batch( + ["DROP TABLE IF EXISTS t", "CREATE TABLE t (a)", "INSERT INTO t VALUES (1), (2), (3), (4), (5)"], + "write" + ); + const rs = await c.execute("DELETE FROM t WHERE a >= 3"); + expect(rs.rowsAffected).toStrictEqual(3); + }) + ); + + //@note lastInsertRowId is not implemented with bun + test.skip( + "lastInsertRowid with INSERT", + withClient(async (c) => { + await c.batch(["DROP TABLE IF EXISTS t", "CREATE TABLE t (a)", "INSERT INTO t VALUES ('one'), ('two')"], "write"); + const insertRs = await c.execute("INSERT INTO t VALUES ('three')"); + expect(insertRs.lastInsertRowid).not.toBeUndefined(); + const selectRs = await c.execute({ + sql: "SELECT a FROM t WHERE ROWID = ?", + args: [insertRs.lastInsertRowid!], + }); + expect(Array.from(selectRs.rows[0])).toStrictEqual(["three"]); + }) + ); + + test( + "rows from INSERT RETURNING", + withClient(async (c) => { + await c.batch(["DROP TABLE IF EXISTS t", "CREATE TABLE t (a)"], "write"); + + const rs = await c.execute("INSERT INTO t VALUES (1) RETURNING 42 AS x, 'foo' AS y"); + expect(rs.columns).toStrictEqual(["x", "y"]); + expect(rs.rows.length).toStrictEqual(1); + expect(Array.from(rs.rows[0])).toStrictEqual([42, "foo"]); + }) + ); + + test( + "rowsAffected with WITH INSERT", + withClient(async (c) => { + await c.batch(["DROP TABLE IF EXISTS t", "CREATE TABLE t (a)", "INSERT INTO t VALUES (1), (2), (3)"], "write"); + + const rs = await c.execute(` + WITH x(a) AS (SELECT 2*a FROM t) + INSERT INTO t SELECT a+1 FROM x + `); + expect(rs.rowsAffected).toStrictEqual(3); + }) + ); +}); + +describe("values", () => { + function testRoundtrip(name: string, passed: libsql.InValue, expected: libsql.Value, intMode?: libsql.IntMode): void { + test( + name, + withClient( + async (c) => { + const rs = await c.execute({ sql: "SELECT ?", args: [passed] }); + expect(rs.rows[0][0]).toStrictEqual(expected); + }, + { intMode } + ) + ); + } + + function testDifference(name: string, passed: libsql.InValue, intMode?: libsql.IntMode): void { + test( + name, + withClient( + async (c) => { + const rs = await c.execute({ sql: "SELECT ?", args: [passed] }); + expect(rs.rows[0][0]).not.toStrictEqual(passed); + }, + { intMode } + ) + ); + } + + function testRoundtripError(name: string, passed: libsql.InValue, expectedError: unknown, intMode?: libsql.IntMode): void { + test( + name, + withClient( + async (c) => { + await expect( + c.execute({ + sql: "SELECT ?", + args: [passed], + }) + ).rejects.toBeInstanceOf(expectedError); + }, + { intMode } + ) + ); + } + + testRoundtrip("string", "boomerang", "boomerang"); + testRoundtrip("string with weird characters", "a\n\r\t ", "a\n\r\t "); + testRoundtrip("string with unicode", "žluťoučký kůň úpěl ďábelské ódy", "žluťoučký kůň úpěl ďábelské ódy"); + + testRoundtrip("zero number", 0, 0); + testRoundtrip("integer number", -2023, -2023); + testRoundtrip("float number", 12.345, 12.345); + + describe("'number' int mode", () => { + testRoundtrip("zero integer", 0n, 0, "number"); + testRoundtrip("small integer", -42n, -42, "number"); + testRoundtrip("largest safe integer", 9007199254740991n, 9007199254740991, "number"); + testDifference("smallest unsafe positive integer", 9007199254740992n, "number"); + testDifference("large unsafe negative integer", -1152921504594532842n, "number"); + }); + + //@note not implemented with bun:sqlite + describe.skip("'bigint' int mode", () => { + testRoundtrip("zero integer", 0n, 0n, "bigint"); + testRoundtrip("small integer", -42n, -42n, "bigint"); + testRoundtrip("large positive integer", 1152921504608088318n, 1152921504608088318n, "bigint"); + testRoundtrip("large negative integer", -1152921504594532842n, -1152921504594532842n, "bigint"); + testRoundtrip("largest positive integer", 9223372036854775807n, 9223372036854775807n, "bigint"); + testRoundtrip("largest negative integer", -9223372036854775808n, -9223372036854775808n, "bigint"); + }); + + //@note not implemented with bun:sqlite + describe.skip("'string' int mode", () => { + testRoundtrip("zero integer", 0n, "0", "string"); + testRoundtrip("small integer", -42n, "-42", "string"); + testRoundtrip("large positive integer", 1152921504608088318n, "1152921504608088318", "string"); + testRoundtrip("large negative integer", -1152921504594532842n, "-1152921504594532842", "string"); + testRoundtrip("largest positive integer", 9223372036854775807n, "9223372036854775807", "string"); + testRoundtrip("largest negative integer", -9223372036854775808n, "-9223372036854775808", "string"); + }); + + const buf = new ArrayBuffer(256); + const array = new Uint8Array(buf); + for (let i = 0; i < 256; ++i) { + array[i] = i ^ 0xab; + } + testRoundtrip("ArrayBuffer", buf, buf); + testRoundtrip("Uint8Array", array, buf); + + testRoundtrip("null", null, null); + testRoundtrip("true", true, 1); + testRoundtrip("false", false, 0); + + testRoundtrip("bigint", -1000n, -1000); + testRoundtrip("Date", new Date("2023-01-02T12:34:56Z"), 1672662896000); + + //@ts-expect-error this tests for an error + testRoundtripError("undefined produces error", undefined, TypeError); + testRoundtripError("NaN produces error", NaN, RangeError); + testRoundtripError("Infinity produces error", Infinity, RangeError); + testRoundtripError("large bigint produces error", -1267650600228229401496703205376n, RangeError); + + test( + "max 64-bit bigint", + withClient(async (c) => { + const rs = await c.execute({ sql: "SELECT ?||''", args: [9223372036854775807n] }); + expect(rs.rows[0][0]).toStrictEqual("9223372036854775807"); + }) + ); + + test( + "min 64-bit bigint", + withClient(async (c) => { + const rs = await c.execute({ sql: "SELECT ?||''", args: [-9223372036854775808n] }); + expect(rs.rows[0][0]).toStrictEqual("-9223372036854775808"); + }) + ); +}); + +describe("ResultSet.toJSON()", () => { + test( + "simple result set", + withClient(async (c) => { + const rs = await c.execute("SELECT 1 AS a"); + const json = rs.toJSON(); + expect(json["lastInsertRowid"] === null || json["lastInsertRowid"] === "0").toBe(true); + expect(json["columns"]).toStrictEqual(["a"]); + expect(json["rows"]).toStrictEqual([[1]]); + expect(json["rowsAffected"]).toStrictEqual(0); + + const str = JSON.stringify(rs); + expect( + str === '{"columns":["a"],"rows":[[1]],"rowsAffected":0,"lastInsertRowid":null}' || + str === '{"columns":["a"],"rows":[[1]],"rowsAffected":0,"lastInsertRowid":"0"}' + ).toBe(true); + }) + ); + + test( + "lastInsertRowid", + withClient(async (c) => { + await c.execute("DROP TABLE IF EXISTS t"); + await c.execute("CREATE TABLE t (id INTEGER PRIMARY KEY NOT NULL)"); + const rs = await c.execute("INSERT INTO t VALUES (12345)"); + expect(rs.toJSON()).toStrictEqual({ + columns: [], + rows: [], + rowsAffected: 1, + lastInsertRowid: "0", //@note not implemented with bun + }); + }) + ); + + test( + "row values", + withClient(async (c) => { + const rs = await c.execute("SELECT 42 AS integer, 0.5 AS float, NULL AS \"null\", 'foo' AS text, X'626172' AS blob"); + const json = rs.toJSON(); + expect(json["columns"]).toStrictEqual(["integer", "float", "null", "text", "blob"]); + expect(json["rows"]).toStrictEqual([[42, 0.5, null, "foo", "YmFy"]]); + }) + ); + + //@note not implemented with bun:sqlite + test.skip( + "bigint row value", + withClient( + async (c) => { + const rs = await c.execute("SELECT 42"); + const json = rs.toJSON(); + expect(json["rows"]).toStrictEqual([["42"]]); + }, + { intMode: "bigint" } + ) + ); +}); + +describe("arguments", () => { + test( + "? arguments", + withClient(async (c) => { + const rs = await c.execute({ + sql: "SELECT ?1, ?2", + args: ["one", "two"], + }); + expect(Array.from(rs.rows[0])).toStrictEqual(["one", "two"]); + }) + ); + + test( + "?NNN arguments", + withClient(async (c) => { + const rs = await c.execute({ + sql: "SELECT ?2, ?3, ?1", + args: ["one", "two", "three"], + }); + expect(Array.from(rs.rows[0])).toStrictEqual(["two", "three", "one"]); + }) + ); + + test( + "?NNN arguments with holes", + withClient(async (c) => { + const rs = await c.execute({ + sql: "SELECT ?3, ?1", + args: ["one", "two", "three"], + }); + expect(Array.from(rs.rows[0])).toStrictEqual(["three", "one"]); + }) + ); + + //@note not supported by bun:sqlite + test( + "?NNN and ? arguments", + withClient(async (c) => { + const rs = await c.execute({ + sql: "SELECT ?2, ?, ?3", + args: ["one", "two", "three"], + }); + expect(Array.from(rs.rows[0])).toStrictEqual(["two", "three", "three"]); + }) + ); + + for (const sign of [":", "@", "$"]) { + test( + `${sign}AAAA arguments`, + withClient(async (c) => { + const rs = await c.execute({ + sql: `SELECT ${sign}b, ${sign}a`, + args: { [`${sign}a`]: "one", [`${sign}b`]: "two" }, + }); + expect(Array.from(rs.rows[0])).toStrictEqual(["two", "one"]); + }) + ); + + test( + `${sign}AAAA arguments used multiple times`, + withClient(async (c) => { + const rs = await c.execute({ + sql: `SELECT ${sign}b, ${sign}a, ${sign}b || ${sign}a`, + args: { [`${sign}a`]: "one", [`${sign}b`]: "two" }, + }); + expect(Array.from(rs.rows[0])).toStrictEqual(["two", "one", "twoone"]); + }) + ); + + test( + `${sign}AAAA arguments and ?NNN arguments`, + withClient(async (c) => { + const rs = await c.execute({ + sql: `SELECT ${sign}b, ${sign}a, ?1`, + args: { [`${sign}a`]: "one", [`${sign}b`]: "two" }, + }); + expect(Array.from(rs.rows[0])).toStrictEqual(["two", "one", "two"]); + }) + ); + } +}); + +describe("batch()", () => { + test( + "multiple queries", + withClient(async (c) => { + const rss = await c.batch( + [ + "SELECT 1+1", + "SELECT 1 AS one, 2 AS two", + { sql: "SELECT ?", args: ["boomerang"] }, + { sql: "VALUES (?), (?)", args: ["big", "ben"] }, + ], + "read" + ); + + expect(rss.length).toStrictEqual(4); + const [rs0, rs1, rs2, rs3] = rss; + + expect(rs0.rows.length).toStrictEqual(1); + expect(Array.from(rs0.rows[0])).toStrictEqual([2]); + + expect(rs1.rows.length).toStrictEqual(1); + expect(Array.from(rs1.rows[0])).toStrictEqual([1, 2]); + + expect(rs2.rows.length).toStrictEqual(1); + expect(Array.from(rs2.rows[0])).toStrictEqual(["boomerang"]); + + expect(rs3.rows.length).toStrictEqual(2); + expect(Array.from(rs3.rows[0])).toStrictEqual(["big"]); + expect(Array.from(rs3.rows[1])).toStrictEqual(["ben"]); + }) + ); + + test( + "statements are executed sequentially", + withClient(async (c) => { + const rss = await c.batch( + [ + /* 0 */ "DROP TABLE IF EXISTS t", + /* 1 */ "CREATE TABLE t (a, b)", + /* 2 */ "INSERT INTO t VALUES (1, 'one')", + /* 3 */ "SELECT * FROM t ORDER BY a", + /* 4 */ "INSERT INTO t VALUES (2, 'two')", + /* 5 */ "SELECT * FROM t ORDER BY a", + /* 6 */ "DROP TABLE t", + ], + "write" + ); + + expect(rss.length).toStrictEqual(7); + expect(rss[3].rows).toEqual([{ a: 1, b: "one" }]); + expect(rss[5].rows).toEqual([ + { a: 1, b: "one" }, + { a: 2, b: "two" }, + ]); + }) + ); + + test( + "statements are executed in a transaction", + withClient(async (c) => { + await c.batch( + ["DROP TABLE IF EXISTS t1", "DROP TABLE IF EXISTS t2", "CREATE TABLE t1 (a)", "CREATE TABLE t2 (a)"], + "write" + ); + + const n = 100; + const promises: Promise[] = []; + for (let i = 0; i < n; ++i) { + const ii = i; + promises.push( + (async () => { + const rss = await c.batch( + [ + { sql: "INSERT INTO t1 VALUES (?)", args: [ii] }, + { sql: "INSERT INTO t2 VALUES (?)", args: [ii * 10] }, + "SELECT SUM(a) FROM t1", + "SELECT SUM(a) FROM t2", + ], + "write" + ); + + const sum1 = rss[2].rows[0][0] as number; + const sum2 = rss[3].rows[0][0] as number; + expect(sum2).toStrictEqual(sum1 * 10); + })() + ); + } + await Promise.all(promises); + + const rs1 = await c.execute("SELECT SUM(a) FROM t1"); + expect(rs1.rows[0][0]).toStrictEqual((n * (n - 1)) / 2); + const rs2 = await c.execute("SELECT SUM(a) FROM t2"); + expect(rs2.rows[0][0]).toStrictEqual(((n * (n - 1)) / 2) * 10); + }), + 10000 + ); + + test( + "error in batch", + withClient(async (c) => { + await expectBunSqliteError(() => c.batch(["SELECT 1+1", "SELECT foobar"], "read")); + }) + ); + + test( + "error in batch rolls back transaction", + withClient(async (c) => { + await c.execute("DROP TABLE IF EXISTS t"); + await c.execute("CREATE TABLE t (a)"); + await c.execute("INSERT INTO t VALUES ('one')"); + await expectBunSqliteError(() => + c.batch(["INSERT INTO t VALUES ('two')", "SELECT foobar", "INSERT INTO t VALUES ('three')"], "write") + ); + + const rs = await c.execute("SELECT COUNT(*) FROM t"); + expect(rs.rows[0][0]).toStrictEqual(1); + }) + ); + + test( + "batch with a lot of different statements", + withClient(async (c) => { + const stmts: string[] = []; + for (let i = 0; i < 1000; ++i) { + stmts.push(`SELECT ${i}`); + } + const rss = await c.batch(stmts, "read"); + for (let i = 0; i < stmts.length; ++i) { + expect(rss[i].rows[0][0]).toStrictEqual(i); + } + }) + ); + + test( + "batch with a lot of the same statements", + withClient(async (c) => { + const n = 2; + const m = 3; + + const stmts: libsql.InStatement[] = []; + for (let i = 0; i < n; ++i) { + for (let j = 0; j < m; ++j) { + stmts.push({ sql: `SELECT $a, $b`, args: { $a: i, $b: j } }); + } + } + + const rss = await c.batch(stmts, "read"); + for (let i = 0; i < n; ++i) { + for (let j = 0; j < m; ++j) { + const rs = rss[i * m + j]; + expect(rs.rows[0][0]).toStrictEqual(i); + expect(rs.rows[0][1]).toStrictEqual(j); + } + } + }) + ); + + test( + "deferred batch", + withClient(async (c) => { + const rss = await c.batch( + ["SELECT 1+1", "DROP TABLE IF EXISTS t", "CREATE TABLE t (a)", "INSERT INTO t VALUES (21) RETURNING 2*a"], + "deferred" + ); + expect(rss.length).toStrictEqual(4); + const [rs0, _rs1, _rs2, rs3] = rss; + + expect(rs0.rows.length).toStrictEqual(1); + expect(Array.from(rs0.rows[0])).toStrictEqual([2]); + + expect(rs3.rows.length).toStrictEqual(1); + expect(Array.from(rs3.rows[0])).toStrictEqual([42]); + }) + ); + + test( + "ROLLBACK statement stops execution of batch", + withClient(async (c) => { + await c.execute("DROP TABLE IF EXISTS t"); + await c.execute("CREATE TABLE t (a)"); + + await expectLibSqlError(() => + c.batch(["INSERT INTO t VALUES (1), (2), (3)", "ROLLBACK", "INSERT INTO t VALUES (4), (5)"], "write") + ); + + const rs = await c.execute("SELECT COUNT(*) FROM t"); + expect(rs.rows[0][0]).toStrictEqual(0); + }) + ); +}); + +describe("transaction()", () => { + test( + "query multiple rows", + withClient(async (c) => { + const txn = await c.transaction("read"); + + const rs = await txn.execute("VALUES (1, 'one'), (2, 'two'), (3, 'three')"); + expect(rs.columns.length).toStrictEqual(2); + expect(rs.rows.length).toStrictEqual(3); + + expect(Array.from(rs.rows[0])).toStrictEqual([1, "one"]); + expect(Array.from(rs.rows[1])).toStrictEqual([2, "two"]); + expect(Array.from(rs.rows[2])).toStrictEqual([3, "three"]); + + txn.close(); + }) + ); + + test( + "commit()", + withClient(async (c) => { + await c.batch(["DROP TABLE IF EXISTS t", "CREATE TABLE t (a)"], "write"); + + const txn = await c.transaction("write"); + await txn.execute("INSERT INTO t VALUES ('one')"); + await txn.execute("INSERT INTO t VALUES ('two')"); + expect(txn.closed).toStrictEqual(false); + await txn.commit(); + expect(txn.closed).toStrictEqual(true); + + const rs = await c.execute("SELECT COUNT(*) FROM t"); + expect(rs.rows[0][0]).toStrictEqual(2); + await expectLibSqlError(() => txn.execute("SELECT 1"), withPattern("TRANSACTION_CLOSED")); + }) + ); + + test( + "rollback()", + withClient(async (c) => { + await c.batch(["DROP TABLE IF EXISTS t", "CREATE TABLE t (a)"], "write"); + + const txn = await c.transaction("write"); + await txn.execute("INSERT INTO t VALUES ('one')"); + await txn.execute("INSERT INTO t VALUES ('two')"); + expect(txn.closed).toStrictEqual(false); + await txn.rollback(); + expect(txn.closed).toStrictEqual(true); + + const rs = await c.execute("SELECT COUNT(*) FROM t"); + expect(rs.rows[0][0]).toStrictEqual(0); + await expectLibSqlError(() => txn.execute("SELECT 1"), withPattern("TRANSACTION_CLOSED")); + }) + ); + + test( + "close()", + withClient(async (c) => { + await c.batch(["DROP TABLE IF EXISTS t", "CREATE TABLE t (a)"], "write"); + + const txn = await c.transaction("write"); + await txn.execute("INSERT INTO t VALUES ('one')"); + expect(txn.closed).toStrictEqual(false); + txn.close(); + expect(txn.closed).toStrictEqual(true); + + const rs = await c.execute("SELECT COUNT(*) FROM t"); + expect(rs.rows[0][0]).toStrictEqual(0); + await expectLibSqlError(() => txn.execute("SELECT 1"), withPattern("TRANSACTION_CLOSED")); + }) + ); + + test( + "error does not rollback", + withClient(async (c) => { + await c.batch(["DROP TABLE IF EXISTS t", "CREATE TABLE t (a)"], "write"); + + const txn = await c.transaction("write"); + await expectBunSqliteError(() => txn.execute("SELECT foo")); + + await txn.execute("INSERT INTO t VALUES ('one')"); + await expectBunSqliteError(() => txn.execute("SELECT bar")); + + await txn.commit(); + + const rs = await c.execute("SELECT COUNT(*) FROM t"); + expect(rs.rows[0][0]).toStrictEqual(1); + }) + ); + + test( + "ROLLBACK statement stops execution of transaction", + withClient(async (c) => { + await c.execute("DROP TABLE IF EXISTS t"); + await c.execute("CREATE TABLE t (a)"); + + const txn = await c.transaction("write"); + const prom1 = txn.execute("INSERT INTO t VALUES (1), (2), (3)"); + const prom2 = txn.execute("ROLLBACK"); + const prom3 = txn.execute("INSERT INTO t VALUES (4), (5)"); + + await prom1; + await prom2; + await expectLibSqlError(() => prom3, withPattern("TRANSACTION_CLOSED")); + await expectLibSqlError(() => txn.commit()); + txn.close(); + + const rs = await c.execute("SELECT COUNT(*) FROM t"); + expect(rs.rows[0][0]).toStrictEqual(0); + }) + ); + + test( + "OR ROLLBACK statement stops execution of transaction", + withClient(async (c) => { + await c.execute("DROP TABLE IF EXISTS t"); + await c.execute("CREATE TABLE t (a UNIQUE)"); + + const txn = await c.transaction("write"); + const prom1 = txn.execute("INSERT INTO t VALUES (1), (2), (3)"); + const prom2 = txn.execute("INSERT OR ROLLBACK INTO t VALUES (1)"); + const prom3 = txn.execute("INSERT INTO t VALUES (4), (5)"); + + await prom1; + await expectBunSqliteError(() => prom2); + await expectLibSqlError(() => prom3, withPattern("TRANSACTION_CLOSED")); + await expectLibSqlError(() => txn.commit(), withPattern("TRANSACTION_CLOSED")); + txn.close(); + + const rs = await c.execute("SELECT COUNT(*) FROM t"); + expect(rs.rows[0][0]).toStrictEqual(0); + }) + ); + + test( + "OR ROLLBACK as the first statement stops execution of transaction", + withClient(async (c) => { + await c.execute("DROP TABLE IF EXISTS t"); + await c.execute("CREATE TABLE t (a UNIQUE)"); + await c.execute("INSERT INTO t VALUES (1), (2), (3)"); + + const txn = await c.transaction("write"); + const prom1 = txn.execute("INSERT OR ROLLBACK INTO t VALUES (1)"); + const prom2 = txn.execute("INSERT INTO t VALUES (4), (5)"); + + await expectBunSqliteError(() => prom1); + await expectLibSqlError(() => prom2, withPattern("TRANSACTION_CLOSED")); + await expectLibSqlError(() => txn.commit(), withPattern("TRANSACTION_CLOSED")); + + txn.close(); + + const rs = await c.execute("SELECT COUNT(*) FROM t"); + expect(rs.rows[0][0]).toStrictEqual(3); + }) + ); + + test( + "commit empty", + withClient(async (c) => { + const txn = await c.transaction("read"); + await txn.commit(); + }) + ); + + test( + "rollback empty", + withClient(async (c) => { + const txn = await c.transaction("read"); + await txn.rollback(); + }) + ); +}); + +describe("batch()", () => { + test( + "as the first operation on transaction", + withClient(async (c) => { + const txn = await c.transaction("write"); + + await txn.batch([ + "DROP TABLE IF EXISTS t", + "CREATE TABLE t (a)", + { sql: "INSERT INTO t VALUES (?)", args: [1] }, + { sql: "INSERT INTO t VALUES (?)", args: [2] }, + { sql: "INSERT INTO t VALUES (?)", args: [4] }, + ]); + + const rs = await txn.execute("SELECT SUM(a) FROM t"); + expect(rs.rows[0][0]).toStrictEqual(7); + txn.close(); + }) + ); + + test( + "as the second operation on transaction", + withClient(async (c) => { + const txn = await c.transaction("write"); + + await txn.execute("DROP TABLE IF EXISTS t"); + await txn.batch([ + "CREATE TABLE t (a)", + { sql: "INSERT INTO t VALUES (?)", args: [1] }, + { sql: "INSERT INTO t VALUES (?)", args: [2] }, + { sql: "INSERT INTO t VALUES (?)", args: [4] }, + ]); + + const rs = await txn.execute("SELECT SUM(a) FROM t"); + expect(rs.rows[0][0]).toStrictEqual(7); + txn.close(); + }) + ); + + test( + "after error, further statements are not executed", + withClient(async (c) => { + const txn = await c.transaction("write"); + + await expectBunSqliteError(() => + txn.batch([ + "DROP TABLE IF EXISTS t", + "CREATE TABLE t (a UNIQUE)", + "INSERT INTO t VALUES (1), (2), (4)", + "INSERT INTO t VALUES (1)", + "INSERT INTO t VALUES (8), (16)", + ]) + ); + const rs = await txn.execute("SELECT SUM(a) FROM t"); + expect(rs.rows[0][0]).toStrictEqual(7); + + await txn.commit(); + }) + ); +}); + +//@note Bun:sqlite doesn't implement executeMultiple due to lack of mulitine statement support. +describe.skip("executeMultiple()", () => { + test( + "as the first operation on transaction", + withClient(async (c) => { + const txn = await c.transaction("write"); + + await txn.executeMultiple( + `DROP TABLE IF EXISTS t; + CREATE TABLE t (a); + INSERT INTO t VALUES (1), (2), (4), (8);` + ); + + const rs = await txn.execute("SELECT SUM(a) FROM t"); + expect(rs.rows[0][0]).toStrictEqual(15); + txn.close(); + }) + ); + + test( + "as the second operation on transaction", + withClient(async (c) => { + const txn = await c.transaction("write"); + await txn.execute("DROP TABLE IF EXISTS t"); + await txn.executeMultiple(` + CREATE TABLE t (a); + INSERT INTO t VALUES (1), (2), (4), (8); + `); + + const rs = await txn.execute("SELECT SUM(a) FROM t"); + expect(rs.rows[0][0]).toStrictEqual(15); + txn.close(); + }) + ); + + test( + "after error, further statements are not executed", + withClient(async (c) => { + const txn = await c.transaction("write"); + + await expectBunSqliteError(() => + txn.executeMultiple(` + DROP TABLE IF EXISTS t; + CREATE TABLE t (a UNIQUE); + INSERT INTO t VALUES (1), (2), (4); + INSERT INTO t VALUES (1); + INSERT INTO t VALUES (8), (16);`) + ); + const rs = await txn.execute("SELECT SUM(a) FROM t"); + expect(rs.rows[0][0]).toStrictEqual(7); + + await txn.commit(); + }) + ); + + test( + "multiple statements", + withClient(async (c) => { + await c.executeMultiple(` + DROP TABLE IF EXISTS t; + CREATE TABLE t (a); + INSERT INTO t VALUES (1), (2), (4), (8); + `); + const rs = await c.execute("SELECT SUM(a) FROM t"); + expect(rs.rows[0][0]).toStrictEqual(15); + }) + ); + + test( + "after an error, statements are not executed", + withClient(async (c) => { + await expectBunSqliteError(() => + c.executeMultiple(` + DROP TABLE IF EXISTS t; + CREATE TABLE t (a); + INSERT INTO t VALUES (1), (2), (4); + INSERT INTO t VALUES (foo()); + INSERT INTO t VALUES (100), (1000);`) + ); + const rs = await c.execute("SELECT SUM(a) FROM t"); + expect(rs.rows[0][0]).toStrictEqual(15); + }) + ); + + test( + "manual transaction control statements", + withClient(async (c) => { + await c.executeMultiple(` + DROP TABLE IF EXISTS t; + CREATE TABLE t (a); + BEGIN; + INSERT INTO t VALUES (1), (2), (4); + INSERT INTO t VALUES (8), (16); + COMMIT; + `); + + const rs = await c.execute("SELECT SUM(a) FROM t"); + expect(rs.rows[0][0]).toStrictEqual(31); + }) + ); + + test( + "error rolls back a manual transaction", + withClient(async (c) => { + await expect( + c.executeMultiple(` + DROP TABLE IF EXISTS t; + CREATE TABLE t (a); + INSERT INTO t VALUES (0); + BEGIN; + INSERT INTO t VALUES (1), (2), (4); + INSERT INTO t VALUES (foo()); + INSERT INTO t VALUES (8), (16); + COMMIT; + `) + ).toThrow(); + // .rejects.toBeLibsqlError(); + const rs = await c.execute("SELECT SUM(a) FROM t"); + expect(rs.rows[0][0]).toStrictEqual(0); + }) + ); +}); + +//@note bun implementation is tested locally. +describe.skip("network errors", () => { + const testCases = [ + { title: "WebSocket close", sql: ".close_ws" }, + { title: "TCP close", sql: ".close_tcp" }, + ]; + + for (const { title, sql } of testCases) { + test( + `${title} in execute()`, + withClient(async (c) => { + await expect(c.execute(sql)).toThrow(); + // .rejects.toBeLibsqlError("HRANA_WEBSOCKET_ERROR"); + + expect((await c.execute("SELECT 42")).rows[0][0]).toStrictEqual(42); + }) + ); + + test( + `${title} in transaction()`, + withClient(async (c) => { + const txn = await c.transaction("read"); + await expect(txn.execute(sql)).rejects.toThrow(); + // .toBeLibsqlError("HRANA_WEBSOCKET_ERROR"); + await expect(txn.commit()).toThrow(); + // .rejects.toBeLibsqlError("TRANSACTION_CLOSED"); + txn.close(); + + expect((await c.execute("SELECT 42")).rows[0][0]).toStrictEqual(42); + }) + ); + + test( + `${title} in batch()`, + withClient(async (c) => { + await expect(c.batch(["SELECT 42", sql, "SELECT 24"], "read")).toThrow(); + // .rejects.toBeLibsqlError("HRANA_WEBSOCKET_ERROR"); + + expect((await c.execute("SELECT 42")).rows[0][0]).toStrictEqual(42); + }) + ); + } +}); + +//@note bun implementation is tested locally. +test.skip("custom fetch", async () => { + let fetchCalledCount = 0; + function customFetch(request: Request): Promise { + fetchCalledCount += 1; + return fetch(request); + } + + const c = createClient({ ...config, fetch: customFetch }); + try { + const rs = await c.execute("SELECT 42"); + expect(rs.rows[0][0]).toStrictEqual(42); + expect(fetchCalledCount).toBeGreaterThan(0); + } finally { + c.close(); + } +}); diff --git a/src/bun_tests/bun.helpers.ts b/src/bun_tests/bun.helpers.ts new file mode 100644 index 0000000..2aad197 --- /dev/null +++ b/src/bun_tests/bun.helpers.ts @@ -0,0 +1,29 @@ +import { expect } from "bun:test"; + +import { LibsqlError } from "../bun"; + +export const withPattern = (...patterns: Array) => + new RegExp( + patterns + .map((pattern) => + typeof pattern === "string" + ? `(?=.*${pattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")})` + : `(?=.*${pattern.source})` + ) + .join("") + ); + +export const expectLibSqlError = async (f: () => any, pattern?: string | RegExp) => { + try { + await f(); + } catch (e: any) { + expect(e).toBeInstanceOf(LibsqlError); + expect(e.code.length).toBeGreaterThan(0); + if (pattern !== undefined) { + expect(e.message).toMatch(pattern); + } + } +}; + +export const expectBunSqliteError = async (f: () => any, pattern?: string | RegExp) => + expectLibSqlError(f, pattern ? withPattern("BUN_SQLITE ERROR", pattern) : withPattern("BUN_SQLITE ERROR")); diff --git a/src/bun_tests/bun.uri.test.ts b/src/bun_tests/bun.uri.test.ts new file mode 100644 index 0000000..ad96ab8 --- /dev/null +++ b/src/bun_tests/bun.uri.test.ts @@ -0,0 +1,246 @@ +import { describe, test, expect } from "bun:test"; +import { parseUri, encodeBaseUrl } from "../uri"; +import { expectLibSqlError, withPattern } from "./bun.helpers"; + +describe("parseUri()", () => { + test("authority and path", () => { + const cases = [ + { text: "file://localhost", path: "" }, + { text: "file://localhost/", path: "/" }, + { text: "file://localhost/absolute/path", path: "/absolute/path" }, + { text: "file://localhost/k%C5%AF%C5%88", path: "/kůň" } + ]; + for (const { text, path } of cases) { + expect(parseUri(text)).toEqual({ + scheme: "file", + authority: { host: "localhost" }, + path + }); + } + }); + + test("empty authority and path", () => { + const cases = [ + { text: "file:///absolute/path", path: "/absolute/path" }, + { text: "file://", path: "" }, + { text: "file:///k%C5%AF%C5%88", path: "/kůň" } + ]; + for (const { text, path } of cases) { + expect(parseUri(text)).toEqual({ + scheme: "file", + authority: { host: "" }, + path + }); + } + }); + + test("no authority and path", () => { + const cases = [ + { text: "file:/absolute/path", path: "/absolute/path" }, + { text: "file:relative/path", path: "relative/path" }, + { text: "file:", path: "" }, + { text: "file:C:/path/to/file", path: "C:/path/to/file" }, + { text: "file:k%C5%AF%C5%88", path: "kůň" } + ]; + for (const { text, path } of cases) { + expect(parseUri(text)).toEqual({ + scheme: "file", + path + }); + } + }); + + test("authority", () => { + const hosts = [ + { text: "localhost", host: "localhost" }, + { text: "domain.name", host: "domain.name" }, + { text: "some$weird.%20!name", host: "some$weird. !name" }, + { text: "1.20.255.99", host: "1.20.255.99" }, + { text: "[2001:4860:4802:32::a]", host: "2001:4860:4802:32::a" }, + { text: "%61", host: "a" }, + { text: "100%2e100%2e100%2e100", host: "100.100.100.100" }, + { text: "k%C5%AF%C5%88", host: "kůň" } + ]; + const ports = [ + { text: "", port: undefined }, + { text: ":", port: undefined }, + { text: ":0", port: 0 }, + { text: ":99", port: 99 }, + { text: ":65535", port: 65535 } + ]; + const userinfos = [ + { text: "", userinfo: undefined }, + { text: "@", userinfo: { username: "" } }, + { text: "alice@", userinfo: { username: "alice" } }, + { text: "alice:secret@", userinfo: { username: "alice", password: "secret" } }, + { text: "alice:sec:et@", userinfo: { username: "alice", password: "sec:et" } }, + { text: "alice%3Asecret@", userinfo: { username: "alice:secret" } }, + { text: "alice:s%65cret@", userinfo: { username: "alice", password: "secret" } } + ]; + + for (const { text: hostText, host } of hosts) { + for (const { text: portText, port } of ports) { + for (const { text: userText, userinfo } of userinfos) { + const text = `http://${userText}${hostText}${portText}`; + expect(parseUri(text)).toEqual({ + scheme: "http", + authority: { host, port, userinfo }, + path: "" + }); + } + } + } + }); + + test("query", () => { + const cases = [ + { text: "?", pairs: [] }, + { text: "?key=value", pairs: [{ key: "key", value: "value" }] }, + { text: "?&key=value", pairs: [{ key: "key", value: "value" }] }, + { text: "?key=value&&", pairs: [{ key: "key", value: "value" }] }, + { text: "?a", pairs: [{ key: "a", value: "" }] }, + { text: "?a=", pairs: [{ key: "a", value: "" }] }, + { text: "?=a", pairs: [{ key: "", value: "a" }] }, + { text: "?=", pairs: [{ key: "", value: "" }] }, + { text: "?a=b=c", pairs: [{ key: "a", value: "b=c" }] }, + { + text: "?a=b&c=d", + pairs: [ + { key: "a", value: "b" }, + { key: "c", value: "d" } + ] + }, + { text: "?a+b=c", pairs: [{ key: "a b", value: "c" }] }, + { text: "?a=b+c", pairs: [{ key: "a", value: "b c" }] }, + { text: "?a?b", pairs: [{ key: "a?b", value: "" }] }, + { text: "?%61=%62", pairs: [{ key: "a", value: "b" }] }, + { text: "?a%3db", pairs: [{ key: "a=b", value: "" }] }, + { text: "?a=%2b", pairs: [{ key: "a", value: "+" }] }, + { text: "?%2b=b", pairs: [{ key: "+", value: "b" }] }, + { text: "?a=b%26c", pairs: [{ key: "a", value: "b&c" }] }, + { text: "?a=k%C5%AF%C5%88", pairs: [{ key: "a", value: "kůň" }] } + ]; + for (const { text: queryText, pairs } of cases) { + const text = `file:${queryText}`; + expect(parseUri(text)).toEqual({ + scheme: "file", + path: "", + query: { pairs } + }); + } + }); + + test("fragment", () => { + const cases = [ + { text: "", fragment: undefined }, + { text: "#a", fragment: "a" }, + { text: "#a?b", fragment: "a?b" }, + { text: "#%61", fragment: "a" }, + { text: "#k%C5%AF%C5%88", fragment: "kůň" } + ]; + for (const { text: fragmentText, fragment } of cases) { + const text = `file:${fragmentText}`; + expect(parseUri(text)).toEqual({ + scheme: "file", + path: "", + fragment + }); + } + }); + + test("parse errors", () => { + const cases = [ + { text: "", message: /format/ }, + { text: "foo", message: /format/ }, + { text: "foo.bar.com", message: /format/ }, + { text: "h$$p://localhost", message: /format/ }, + { text: "h%74%74p://localhost", message: /format/ }, + { text: "http://localhost:%38%38", message: /authority/ }, + { text: "file:k%C5%C5%88", message: /percent encoding/ } + ]; + + for (const { text, message } of cases) { + expectLibSqlError(() => parseUri(text), withPattern("URL_INVALID", message)); + } + }); +}); + +test("encodeBaseUrl()", () => { + const cases = [ + { + scheme: "http", + host: "localhost", + path: "", + url: "http://localhost" + }, + { + scheme: "http", + host: "localhost", + path: "/", + url: "http://localhost/" + }, + { + scheme: "http", + host: "localhost", + port: 8080, + path: "", + url: "http://localhost:8080" + }, + { + scheme: "http", + host: "localhost", + path: "/foo/bar", + url: "http://localhost/foo/bar" + }, + { + scheme: "http", + host: "localhost", + path: "foo/bar", + url: "http://localhost/foo/bar" + }, + { + scheme: "http", + host: "some.long.domain.name", + path: "", + url: "http://some.long.domain.name" + }, + { + scheme: "http", + host: "1.2.3.4", + path: "", + url: "http://1.2.3.4" + }, + { + scheme: "http", + host: "2001:4860:4802:32::a", + path: "", + url: "http://[2001:4860:4802:32::a]" + }, + { + scheme: "http", + host: "localhost", + userinfo: { username: "alice", password: undefined }, + path: "", + url: "http://alice@localhost" + }, + { + scheme: "http", + host: "localhost", + userinfo: { username: "alice", password: "secr:t" }, + path: "", + url: "http://alice:secr%3At@localhost" + }, + { + scheme: "https", + host: "localhost", + userinfo: { username: "alice", password: "secret" }, + port: 8080, + path: "/some/path", + url: "https://alice:secret@localhost:8080/some/path" + } + ]; + + for (const { scheme, host, port, userinfo, path, url } of cases) { + expect(encodeBaseUrl(scheme, { host, port, userinfo }, path)).toEqual(new URL(url)); + } +}); diff --git a/src/node.ts b/src/node.ts index f0800bb..4ba096e 100644 --- a/src/node.ts +++ b/src/node.ts @@ -1,5 +1,4 @@ import type { Config, Client } from "./api.js"; -import { LibsqlError } from "./api.js"; import type { ExpandedConfig } from "./config.js"; import { expandConfig } from "./config.js"; import { _createClient as _createSqlite3Client } from "./sqlite3.js"; diff --git a/src/sqlite3.ts b/src/sqlite3.ts index c5e32b4..fdefade 100644 --- a/src/sqlite3.ts +++ b/src/sqlite3.ts @@ -2,51 +2,38 @@ import Database from "better-sqlite3"; import { Buffer } from "node:buffer"; import type { - Config, IntMode, Client, Transaction, TransactionMode, - ResultSet, Row, Value, InValue, InStatement, + Config, + IntMode, + Client, + Transaction, + TransactionMode, + ResultSet, + Row, + Value, + InValue, + InStatement, } from "./api.js"; import { LibsqlError } from "./api.js"; -import type { ExpandedConfig } from "./config.js"; -import { expandConfig } from "./config.js"; -import { supportedUrlLink, transactionModeToBegin, ResultSetImpl } from "./util.js"; +import { expandConfig, type ExpandedConfig } from "./config.js"; +import { + transactionModeToBegin, + ResultSetImpl, + validateFileConfig, + parseStatement, + minSafeBigint, + maxSafeBigint, + minInteger, + maxInteger, +} from "./util.js"; export * from "./api.js"; export function createClient(config: Config): Client { - return _createClient(expandConfig(config, true)); + return _createClient(validateFileConfig(expandConfig(config, true))); } /** @private */ export function _createClient(config: ExpandedConfig): Client { - if (config.scheme !== "file") { - throw new LibsqlError( - `URL scheme ${JSON.stringify(config.scheme + ":")} is not supported by the local sqlite3 client. ` + - `For more information, please read ${supportedUrlLink}`, - "URL_SCHEME_NOT_SUPPORTED", - ); - } - - const authority = config.authority; - if (authority !== undefined) { - const host = authority.host.toLowerCase(); - if (host !== "" && host !== "localhost") { - throw new LibsqlError( - `Invalid host in file URL: ${JSON.stringify(authority.host)}. ` + - 'A "file:" URL with an absolute path should start with one slash ("file:/absolute/path.db") ' + - 'or with three slashes ("file:///absolute/path.db"). ' + - `For more information, please read ${supportedUrlLink}`, - "URL_INVALID", - ); - } - - if (authority.port !== undefined) { - throw new LibsqlError("File URL cannot have a port", "URL_INVALID"); - } - if (authority.userinfo !== undefined) { - throw new LibsqlError("File URL cannot have username and password", "URL_INVALID"); - } - } - const path = config.path; const options = {}; @@ -97,7 +84,7 @@ export class Sqlite3Client implements Client { } return executeStmt(db, stmt, this.#intMode); }); - executeStmt(db, "COMMIT", this.#intMode) + executeStmt(db, "COMMIT", this.#intMode); return resultSets; } finally { db.close(); @@ -195,24 +182,8 @@ export class Sqlite3Transaction implements Transaction { } function executeStmt(db: Database.Database, stmt: InStatement, intMode: IntMode): ResultSet { - let sql: string; - let args: Array | Record; - if (typeof stmt === "string") { - sql = stmt; - args = []; - } else { - sql = stmt.sql; - if (Array.isArray(stmt.args)) { - args = stmt.args.map(valueToSql); - } else { - args = {}; - for (const name in stmt.args) { - const argName = (name[0] === "@" || name[0] === "$" || name[0] === ":") - ? name.substring(1) : name; - args[argName] = valueToSql(stmt.args[name]); - } - } - } + const transformKeys = (name: string) => (name[0] === "@" || name[0] === "$" || name[0] === ":" ? name.substring(1) : name); + const { sql, args } = parseStatement(stmt, valueToSql, transformKeys); try { const sqlStmt = db.prepare(sql); @@ -227,7 +198,7 @@ function executeStmt(db: Database.Database, stmt: InStatement, intMode: IntMode) } if (returnsData) { - const columns = Array.from(sqlStmt.columns().map(col => col.name)); + const columns = Array.from(sqlStmt.columns().map((col) => col.name)); const rows = sqlStmt.all(args).map((sqlRow) => { return rowFromSql(sqlRow as Array, columns, intMode); }); @@ -266,28 +237,23 @@ function valueFromSql(sqlValue: unknown, intMode: IntMode): Value { if (typeof sqlValue === "bigint") { if (intMode === "number") { if (sqlValue < minSafeBigint || sqlValue > maxSafeBigint) { - throw new RangeError( - "Received integer which cannot be safely represented as a JavaScript number" - ); + throw new RangeError("Received integer which cannot be safely represented as a JavaScript number"); } return Number(sqlValue); } else if (intMode === "bigint") { return sqlValue; } else if (intMode === "string") { - return ""+sqlValue; + return "" + sqlValue; } else { throw new Error("Invalid value for IntMode"); } } else if (sqlValue instanceof Buffer) { - return sqlValue.buffer; + return sqlValue.buffer as Value; } return sqlValue as Value; } -const minSafeBigint = -9007199254740991n; -const maxSafeBigint = 9007199254740991n; - -function valueToSql(value: InValue): unknown { +function valueToSql(value: InValue) { if (typeof value === "number") { if (!Number.isFinite(value)) { throw new RangeError("Only finite numbers (not Infinity or NaN) can be passed as arguments"); @@ -295,9 +261,7 @@ function valueToSql(value: InValue): unknown { return value; } else if (typeof value === "bigint") { if (value < minInteger || value > maxInteger) { - throw new RangeError( - "bigint is too large to be represented as a 64-bit integer and passed as argument" - ); + throw new RangeError("bigint is too large to be represented as a 64-bit integer and passed as argument"); } return value; } else if (typeof value === "boolean") { @@ -313,9 +277,6 @@ function valueToSql(value: InValue): unknown { } } -const minInteger = -9223372036854775808n; -const maxInteger = 9223372036854775807n; - function executeMultiple(db: Database.Database, sql: string): void { try { db.exec(sql); diff --git a/src/util.ts b/src/util.ts index 521b35e..fbc29dc 100644 --- a/src/util.ts +++ b/src/util.ts @@ -1,8 +1,65 @@ import { Base64 } from "js-base64"; -import { ResultSet, Row, Value, TransactionMode, InStatement, LibsqlError } from "./api.js"; +import { ResultSet, Row, Value, TransactionMode, LibsqlError, type InStatement, type InValue } from "./api.js"; +import type { ExpandedConfig } from "./config.js"; export const supportedUrlLink = "https://github.com/libsql/libsql-client-ts#supported-urls"; +type TransformFunction = (v: InValue) => any; +type ParseStatementReturn = + | { + sql: string; + args: []; + } + | { + sql: string; + args: ReturnType[] | { [k: string]: ReturnType }; + }; + +export const parseStatement = ( + stmt: InStatement, + transformValues: T, + transformKeys = (v: string) => v +): ParseStatementReturn => + typeof stmt === "string" + ? { sql: stmt, args: [] } + : { + sql: stmt.sql, + args: Array.isArray(stmt.args) + ? stmt.args.map(transformValues) + : Object.fromEntries(Object.entries(stmt.args).map(([k, v]) => [transformKeys(k), transformValues(v)])), + }; + +export function validateFileConfig(config: ExpandedConfig) { + if (config.scheme !== "file") { + throw new LibsqlError( + `URL scheme ${JSON.stringify(config.scheme + ":")} is not supported by the local sqlite3 client. ` + + `For more information, please read ${supportedUrlLink}`, + "URL_SCHEME_NOT_SUPPORTED" + ); + } + + const authority = config.authority; + if (authority !== undefined) { + const host = authority.host.toLowerCase(); + if (host !== "" && host !== "localhost") { + throw new LibsqlError( + `Invalid host in file URL: ${JSON.stringify(authority.host)}. ` + + 'A "file:" URL with an absolute path should start with one slash ("file:/absolute/path.db") ' + + 'or with three slashes ("file:///absolute/path.db"). ' + + `For more information, please read ${supportedUrlLink}`, + "URL_INVALID" + ); + } + + if (authority.port !== undefined) { + throw new LibsqlError("File URL cannot have a port", "URL_INVALID"); + } + if (authority.userinfo !== undefined) { + throw new LibsqlError("File URL cannot have username and password", "URL_INVALID"); + } + } + return config; +} export function transactionModeToBegin(mode: TransactionMode): string { if (mode === "write") { return "BEGIN IMMEDIATE"; @@ -21,12 +78,7 @@ export class ResultSetImpl implements ResultSet { rowsAffected: number; lastInsertRowid: bigint | undefined; - constructor( - columns: Array, - rows: Array, - rowsAffected: number, - lastInsertRowid: bigint | undefined, - ) { + constructor(columns: Array, rows: Array, rowsAffected: number, lastInsertRowid: bigint | undefined) { this.columns = columns; this.rows = rows; this.rowsAffected = rowsAffected; @@ -35,10 +87,10 @@ export class ResultSetImpl implements ResultSet { toJSON(): any { return { - "columns": this.columns, - "rows": this.rows.map(rowToJson), - "rowsAffected": this.rowsAffected, - "lastInsertRowid": this.lastInsertRowid !== undefined ? ""+this.lastInsertRowid : null, + columns: this.columns, + rows: this.rows.map(rowToJson), + rowsAffected: this.rowsAffected, + lastInsertRowid: this.lastInsertRowid !== undefined ? "" + this.lastInsertRowid : null, }; } } @@ -49,10 +101,15 @@ function rowToJson(row: Row): unknown { function valueToJson(value: Value): unknown { if (typeof value === "bigint") { - return ""+value; + return "" + value; } else if (value instanceof ArrayBuffer) { return Base64.fromUint8Array(new Uint8Array(value)); } else { return value; } } + +export const minInteger = -9223372036854775808n; +export const maxInteger = 9223372036854775807n; +export const minSafeBigint = -9007199254740991n; +export const maxSafeBigint = 9007199254740991n; diff --git a/tsconfig.build-cjs.json b/tsconfig.build-cjs.json index 857027a..920fe44 100644 --- a/tsconfig.build-cjs.json +++ b/tsconfig.build-cjs.json @@ -4,6 +4,6 @@ "module": "commonjs", "declaration": false, "outDir": "./lib-cjs/" - } + }, + "exclude": ["**/__tests__", "**/bun_tests"] } - diff --git a/tsconfig.build-esm.json b/tsconfig.build-esm.json index 9a01705..5337ef0 100644 --- a/tsconfig.build-esm.json +++ b/tsconfig.build-esm.json @@ -4,6 +4,6 @@ "module": "esnext", "declaration": true, "outDir": "./lib-esm/" - } + }, + "exclude": ["**/__tests__", "**/bun_tests"] } - diff --git a/tsconfig.json b/tsconfig.json index bc06427..c52bc3f 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,7 +1,8 @@ { "extends": "./tsconfig.base.json", "compilerOptions": { + "incremental": true, "noEmit": true, - "incremental": true + "types": ["bun-types", "@types/jest"] } }