From e07ec80a445031ba13bebc8eb8fe87ce85205b63 Mon Sep 17 00:00:00 2001 From: Hebilicious Date: Mon, 28 Aug 2023 01:16:49 +0700 Subject: [PATCH 01/24] feat: bun sqlite implementation --- package.json | 27 +++- src/bun-sqlite.ts | 356 +++++++++++++++++++++++++++++++++++++++++++++ src/index.ts | 6 +- tsconfig.base.json | 1 + 4 files changed, 382 insertions(+), 8 deletions(-) create mode 100644 src/bun-sqlite.ts diff --git a/package.json b/package.json index 43529525..be3a2002 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,6 @@ "Jan Plhak " ], "license": "MIT", - "type": "module", "main": "lib-cjs/index.js", "types": "lib-esm/index.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/web.js", "default": "./lib-esm/index.js" }, "require": "./lib-cjs/index.js" @@ -56,21 +56,33 @@ "types": "./lib-esm/web.d.ts", "import": "./lib-esm/web.js", "require": "./lib-cjs/web.js" + }, + "./bun-sqlite": { + "types": "./lib-esm/bun-sqlite.d.ts", + "import": "./lib-esm/bun-sqlite.js", + "require": "./lib-cjs/bun-sqlite.js" } }, "typesVersions": { "*": { - "http": ["./lib-esm/http.d.ts"], - "hrana": ["./lib-esm/hrana.d.ts"], - "sqlite3": ["./lib-esm/sqlite3.d.ts"], - "web": ["./lib-esm/web.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" + ] } }, "files": [ "lib-cjs/**", "lib-esm/**" ], - "scripts": { "prepublishOnly": "npm run build", "prebuild": "rm -rf ./lib-cjs ./lib-esm", @@ -79,10 +91,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", @@ -92,6 +104,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/bun-sqlite.ts b/src/bun-sqlite.ts new file mode 100644 index 00000000..d7c37c58 --- /dev/null +++ b/src/bun-sqlite.ts @@ -0,0 +1,356 @@ +import { Database, SQLQueryBindings } from "bun:sqlite"; + +type ConstructorParameters = T extends new (...args: infer P) => any ? P : never; +type DatabaseOptions = ConstructorParameters[1]; + +import type { + 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"; + +export * from "./api.js"; + +export function createClient(config: Config): Client { + return _createClient(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 = undefined; //implement options + const db = new Database(path); + try { + executeStmt(db, "SELECT 1 AS checkThatTheDatabaseCanBeOpened", config.intMode); + } finally { + db.close(); + } + + return new Sqlite3Client(path, options, config.intMode); +} + +export class Sqlite3Client 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 Sqlite3Transaction(db, this.#intMode); + } catch (e) { + db.close(); + throw e; + } + } + + async executeMultiple(sql: string): Promise { + this.#checkNotClosed(); + const stmts = sql + .split(";") + .map((s) => s.trim()) + .filter(Boolean); + await this.batch(stmts); + } + + close(): void { + this.closed = true; + } + + #checkNotClosed(): void { + if (this.closed) { + throw new LibsqlError("The client is closed", "CLIENT_CLOSED"); + } + } +} + +export class Sqlite3Transaction implements Transaction { + #database: Database; + #intMode: IntMode; + #isClosed: boolean; + + /** @private */ + constructor(database: Database, intMode: IntMode) { + this.#database = database; + this.#intMode = intMode; + this.#isClosed = 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 { + this.#checkNotClosed(); + const stmts = sql + .split(";") + .map((s) => s.trim()) + .filter(Boolean); + await this.batch(stmts); + } + + 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.#isClosed = true; + } + + get closed(): boolean { + return this.#isClosed; + } + + #checkNotClosed(): void { + if (this.#isClosed || !this.#database.inTransaction) { + throw new LibsqlError("The transaction is closed", "TRANSACTION_CLOSED"); + } + } +} + +function executeStmt(db: 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) { + args[name] = valueToSql(stmt.args[name]); + } + } + } + + 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 isBufferLike(obj: unknown): obj is Buffer { + const bufferLike = [ + Int8Array, + Uint8Array, + Uint8ClampedArray, + Int16Array, + Uint16Array, + Int32Array, + Uint32Array, + Float32Array, + Float64Array, + BigInt64Array, + BigUint64Array, + Buffer + ]; + + return bufferLike.some((b) => obj instanceof b); +} + +function valueFromSql(sqlValue: unknown, intMode: IntMode): Value { + // https://github.com/oven-sh/bun/issues/1536 + if (typeof sqlValue === "number") { + 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 BigInt(sqlValue); + } else if (intMode === "string") { + return "" + sqlValue; + } else { + throw new Error("Invalid value for IntMode"); + } + } else if (isBufferLike(sqlValue)) { + return sqlValue.buffer as Value; + } + return sqlValue as Value; +} + +const minSafeBigint = -9007199254740991n; +const maxSafeBigint = 9007199254740991n; + +function valueToSql(value: InValue): unknown { + 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 ? 1 : 0; + } 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 minInteger = -9223372036854775808n; +const maxInteger = 9223372036854775807n; + +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; +} diff --git a/src/index.ts b/src/index.ts index f0800bba..ce026152 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,7 +1,7 @@ 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 _createBunSqliteClient } from "./bun-sqlite.js"; import { _createClient as _createSqlite3Client } from "./sqlite3.js"; import { _createClient as _createWsClient } from "./ws.js"; import { _createClient as _createHttpClient } from "./http.js"; @@ -16,11 +16,15 @@ export function createClient(config: Config): Client { return _createClient(expandConfig(config, true)); } +const isBun = !!globalThis.Bun || !!globalThis.process?.versions?.bun; + function _createClient(config: ExpandedConfig) { if (config.scheme === "wss" || config.scheme === "ws") { return _createWsClient(config); } else if (config.scheme === "https" || config.scheme === "http") { return _createHttpClient(config); + } else if (isBun) { + return _createBunSqliteClient(config); } else { return _createSqlite3Client(config); } diff --git a/tsconfig.base.json b/tsconfig.base.json index e6d67e71..286d0b41 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -1,6 +1,7 @@ { "compilerOptions": { "moduleResolution": "node", + "types": ["bun-types"], "lib": ["esnext"], "target": "esnext", "esModuleInterop": true, From 12990e6ac3ad2d0634e29be419f03a999fa65574 Mon Sep 17 00:00:00 2001 From: Hebilicious Date: Mon, 28 Aug 2023 01:16:58 +0700 Subject: [PATCH 02/24] test: add bun sqlite tests --- src/bun_tests/bun.client.test.ts | 1141 ++++++++++++++++++++++++++++++ src/bun_tests/bun.uri.test.ts | 246 +++++++ 2 files changed, 1387 insertions(+) create mode 100644 src/bun_tests/bun.client.test.ts create mode 100644 src/bun_tests/bun.uri.test.ts diff --git a/src/bun_tests/bun.client.test.ts b/src/bun_tests/bun.client.test.ts new file mode 100644 index 00000000..8a1c4522 --- /dev/null +++ b/src/bun_tests/bun.client.test.ts @@ -0,0 +1,1141 @@ +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 ".."; +import { LibsqlError, createClient } from ".."; + +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(); + } + }; +} + +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); + } + } +}; + +const expectBunSqliteError = 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(withPattern("BUN_SQLITE ERROR", pattern)); + } + } +}; + +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 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"); + testRoundtripError("smallest unsafe integer", 9007199254740992n, RangeError, "number"); + testRoundtripError("large unsafe integer", -1152921504594532842n, RangeError, "number"); + }); + + describe("'bigint' int mode", () => { + testRoundtrip("zero integer", 0n, 0n, "bigint"); + testRoundtrip("small integer", -42n, -42n, "bigint"); + //@note Lack of proper BigInt support // https://github.com/oven-sh/bun/issues/1536 + // 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" + ); + }); + + describe("'string' int mode", () => { + testRoundtrip("zero integer", 0n, "0", "string"); + testRoundtrip("small integer", -42n, "-42", "string"); + //@note Lack of proper BigInt support // https://github.com/oven-sh/bun/issues/1536 + // 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"]]); + }) + ); + + test( + "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", () => { + //@note not supported by bun:sqlite + test.skip( + "? 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.skip( + "?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"]); + }) + ); + + //@note not supported by bun:sqlite + test.skip( + `${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 expectBunSqliteError(() => txn.commit()); + 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 expectBunSqliteError(() => txn.commit()); + + 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(); + }) + ); +}); + +describe("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(); + }) + ); +}); + +describe("executeMultiple()", () => { + 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"); + const rs2 = await c.execute("SELECT * from t"); + //@note bun implementation use batch + expect(rs.rows[0][0]).toStrictEqual(15); + }) + ); + + //@note bun implementation use batch doesn't support manual transactions + test.skip( + "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.skip( + "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); + }) + ); + } +}); + +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.uri.test.ts b/src/bun_tests/bun.uri.test.ts new file mode 100644 index 00000000..ee4669df --- /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.js"; +import { expectLibSqlError, withPattern } from "./bun.client.test.js"; + +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)); + } +}); From 111d6df11ed3438abfc00a10c76d69ebd0d414b7 Mon Sep 17 00:00:00 2001 From: Hebilicious Date: Mon, 28 Aug 2023 02:04:23 +0700 Subject: [PATCH 03/24] feat: add bun entrypoint --- package.json | 8 +++++++- src/bun-sqlite.ts | 12 ++++++------ src/bun.ts | 34 ++++++++++++++++++++++++++++++++++ 3 files changed, 47 insertions(+), 7 deletions(-) create mode 100644 src/bun.ts diff --git a/package.json b/package.json index be3a2002..7b919469 100644 --- a/package.json +++ b/package.json @@ -32,7 +32,7 @@ "deno": "./lib-esm/web.js", "edge-light": "./lib-esm/web.js", "netlify": "./lib-esm/web.js", - "bun": "./lib-esm/web.js", + "bun": "./lib-esm/bun.js", "default": "./lib-esm/index.js" }, "require": "./lib-cjs/index.js" @@ -76,6 +76,12 @@ ], "web": [ "./lib-esm/web.d.ts" + ], + "bun": [ + "./lib-esm/bun.d.ts" + ], + "bun-sqlite": [ + "./lib-esm/bun-sqlite.d.ts" ] } }, diff --git a/src/bun-sqlite.ts b/src/bun-sqlite.ts index d7c37c58..ced56b59 100644 --- a/src/bun-sqlite.ts +++ b/src/bun-sqlite.ts @@ -22,6 +22,12 @@ import { supportedUrlLink, transactionModeToBegin, ResultSetImpl } from "./util. export * from "./api.js"; +/** https://github.com/oven-sh/bun/issues/1536 */ +const minInteger = -9223372036854775808n; +const maxInteger = 9223372036854775807n; +const minSafeBigint = -9007199254740991n; +const maxSafeBigint = 9007199254740991n; + export function createClient(config: Config): Client { return _createClient(expandConfig(config, true)); } @@ -313,9 +319,6 @@ function valueFromSql(sqlValue: unknown, intMode: IntMode): Value { return sqlValue as Value; } -const minSafeBigint = -9007199254740991n; -const maxSafeBigint = 9007199254740991n; - function valueToSql(value: InValue): unknown { if (typeof value === "number") { if (!Number.isFinite(value)) { @@ -342,9 +345,6 @@ function valueToSql(value: InValue): unknown { } } -const minInteger = -9223372036854775808n; -const maxInteger = 9223372036854775807n; - function mapSqliteError(e: unknown): unknown { if (e instanceof RangeError) { return e; diff --git a/src/bun.ts b/src/bun.ts new file mode 100644 index 00000000..ed51accc --- /dev/null +++ b/src/bun.ts @@ -0,0 +1,34 @@ +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)); +} + +/** @private */ +export function _createClient(config: ExpandedConfig): Client { + 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" + ); + } +} From a9fa380b2a9a70e305691ca536e5990103000e5d Mon Sep 17 00:00:00 2001 From: Hebilicious Date: Mon, 28 Aug 2023 17:30:28 +0700 Subject: [PATCH 04/24] chore: fix build errors --- package-lock.json | 7 +++++++ src/bun-sqlite.ts | 1 + src/index.ts | 2 +- tsconfig.base.json | 1 - tsconfig.build-cjs.json | 4 ++-- tsconfig.build-esm.json | 4 ++-- tsconfig.json | 3 ++- 7 files changed, 15 insertions(+), 7 deletions(-) diff --git a/package-lock.json b/package-lock.json index e95262ab..ed5ad47c 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/src/bun-sqlite.ts b/src/bun-sqlite.ts index ced56b59..294a964d 100644 --- a/src/bun-sqlite.ts +++ b/src/bun-sqlite.ts @@ -1,3 +1,4 @@ +// @ts-ignore bun:sqlite is not typed when building import { Database, SQLQueryBindings } from "bun:sqlite"; type ConstructorParameters = T extends new (...args: infer P) => any ? P : never; diff --git a/src/index.ts b/src/index.ts index ce026152..5959d16c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -16,7 +16,7 @@ export function createClient(config: Config): Client { return _createClient(expandConfig(config, true)); } -const isBun = !!globalThis.Bun || !!globalThis.process?.versions?.bun; +const isBun = !!(globalThis as any).Bun || !!(globalThis as any).process?.versions?.bun; function _createClient(config: ExpandedConfig) { if (config.scheme === "wss" || config.scheme === "ws") { diff --git a/tsconfig.base.json b/tsconfig.base.json index 286d0b41..e6d67e71 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -1,7 +1,6 @@ { "compilerOptions": { "moduleResolution": "node", - "types": ["bun-types"], "lib": ["esnext"], "target": "esnext", "esModuleInterop": true, diff --git a/tsconfig.build-cjs.json b/tsconfig.build-cjs.json index 857027a0..920fe448 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 9a01705b..5337ef0c 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 bc064274..2e2f39c1 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"] } } From f3c3552cf79c5ff3720c29850cec576ee1fadf70 Mon Sep 17 00:00:00 2001 From: Hebilicious Date: Mon, 28 Aug 2023 17:31:31 +0700 Subject: [PATCH 05/24] chore: rename to bun_sqlite --- src/bun.ts | 2 +- src/{bun-sqlite.ts => bun_sqlite.ts} | 0 src/index.ts | 2 +- 3 files changed, 2 insertions(+), 2 deletions(-) rename src/{bun-sqlite.ts => bun_sqlite.ts} (100%) diff --git a/src/bun.ts b/src/bun.ts index ed51accc..ab447b13 100644 --- a/src/bun.ts +++ b/src/bun.ts @@ -6,7 +6,7 @@ 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"; +import { _createClient as _createBunSqliteClient } from "./bun_sqlite.js"; export * from "./api.js"; diff --git a/src/bun-sqlite.ts b/src/bun_sqlite.ts similarity index 100% rename from src/bun-sqlite.ts rename to src/bun_sqlite.ts diff --git a/src/index.ts b/src/index.ts index 5959d16c..8759abb5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,7 +1,7 @@ import type { Config, Client } from "./api.js"; import type { ExpandedConfig } from "./config.js"; import { expandConfig } from "./config.js"; -import { _createClient as _createBunSqliteClient } from "./bun-sqlite.js"; +import { _createClient as _createBunSqliteClient } from "./bun_sqlite.js"; import { _createClient as _createSqlite3Client } from "./sqlite3.js"; import { _createClient as _createWsClient } from "./ws.js"; import { _createClient as _createHttpClient } from "./http.js"; From 33437821d0265dd67b926da732335830d28a0f62 Mon Sep 17 00:00:00 2001 From: Hebilicious Date: Mon, 28 Aug 2023 17:34:39 +0700 Subject: [PATCH 06/24] chore: rename bun_sqlite classes --- src/bun_sqlite.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/bun_sqlite.ts b/src/bun_sqlite.ts index 294a964d..1609c083 100644 --- a/src/bun_sqlite.ts +++ b/src/bun_sqlite.ts @@ -75,10 +75,10 @@ export function _createClient(config: ExpandedConfig): Client { db.close(); } - return new Sqlite3Client(path, options, config.intMode); + return new BunSqliteClient(path, options, config.intMode); } -export class Sqlite3Client implements Client { +export class BunSqliteClient implements Client { #path: string; #options: DatabaseOptions; #intMode: IntMode; @@ -130,7 +130,7 @@ export class Sqlite3Client implements Client { const db = new Database(this.#path, this.#options); try { executeStmt(db, transactionModeToBegin(mode), this.#intMode); - return new Sqlite3Transaction(db, this.#intMode); + return new BunSqliteTransaction(db, this.#intMode); } catch (e) { db.close(); throw e; @@ -157,7 +157,7 @@ export class Sqlite3Client implements Client { } } -export class Sqlite3Transaction implements Transaction { +export class BunSqliteTransaction implements Transaction { #database: Database; #intMode: IntMode; #isClosed: boolean; From c59d20867b3607984b5985dda68f1554fec9ea54 Mon Sep 17 00:00:00 2001 From: Hebilicious Date: Mon, 28 Aug 2023 17:41:26 +0700 Subject: [PATCH 07/24] chore: rename private to closed --- src/bun_sqlite.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/bun_sqlite.ts b/src/bun_sqlite.ts index 1609c083..bc3c0903 100644 --- a/src/bun_sqlite.ts +++ b/src/bun_sqlite.ts @@ -160,13 +160,13 @@ export class BunSqliteClient implements Client { export class BunSqliteTransaction implements Transaction { #database: Database; #intMode: IntMode; - #isClosed: boolean; + #closed: boolean; /** @private */ constructor(database: Database, intMode: IntMode) { this.#database = database; this.#intMode = intMode; - this.#isClosed = false; + this.#closed = false; } async execute(stmt: InStatement): Promise { @@ -191,7 +191,7 @@ export class BunSqliteTransaction implements Transaction { } async rollback(): Promise { - if (this.closed) { + if (this.#closed) { return; } this.#checkNotClosed(); @@ -207,15 +207,15 @@ export class BunSqliteTransaction implements Transaction { close(): void { this.#database.close(); - this.#isClosed = true; + this.#closed = true; } get closed(): boolean { - return this.#isClosed; + return this.#closed; } #checkNotClosed(): void { - if (this.#isClosed || !this.#database.inTransaction) { + if (this.#closed || !this.#database.inTransaction) { throw new LibsqlError("The transaction is closed", "TRANSACTION_CLOSED"); } } From 9aaf6316c24f2912c17815824ba568ec26cb70af Mon Sep 17 00:00:00 2001 From: Hebilicious Date: Mon, 28 Aug 2023 17:43:07 +0700 Subject: [PATCH 08/24] refactor: use ArrayBuffer.isView --- src/bun_sqlite.ts | 21 +-------------------- 1 file changed, 1 insertion(+), 20 deletions(-) diff --git a/src/bun_sqlite.ts b/src/bun_sqlite.ts index bc3c0903..48e058ae 100644 --- a/src/bun_sqlite.ts +++ b/src/bun_sqlite.ts @@ -278,25 +278,6 @@ function convertSqlResultToRows(results: Record[], intMode: IntMo }); } -function isBufferLike(obj: unknown): obj is Buffer { - const bufferLike = [ - Int8Array, - Uint8Array, - Uint8ClampedArray, - Int16Array, - Uint16Array, - Int32Array, - Uint32Array, - Float32Array, - Float64Array, - BigInt64Array, - BigUint64Array, - Buffer - ]; - - return bufferLike.some((b) => obj instanceof b); -} - function valueFromSql(sqlValue: unknown, intMode: IntMode): Value { // https://github.com/oven-sh/bun/issues/1536 if (typeof sqlValue === "number") { @@ -314,7 +295,7 @@ function valueFromSql(sqlValue: unknown, intMode: IntMode): Value { } else { throw new Error("Invalid value for IntMode"); } - } else if (isBufferLike(sqlValue)) { + } else if (ArrayBuffer.isView(sqlValue)) { return sqlValue.buffer as Value; } return sqlValue as Value; From 4d9cc7819df15e1779407da22e904ce4d6d1729a Mon Sep 17 00:00:00 2001 From: Hebilicious Date: Mon, 28 Aug 2023 17:49:15 +0700 Subject: [PATCH 09/24] refactor: use booleans --- src/bun_sqlite.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/bun_sqlite.ts b/src/bun_sqlite.ts index 48e058ae..dc77d4df 100644 --- a/src/bun_sqlite.ts +++ b/src/bun_sqlite.ts @@ -67,7 +67,7 @@ export function _createClient(config: ExpandedConfig): Client { } const path = config.path; - const options = undefined; //implement options + const options = undefined; //@todo implement options const db = new Database(path); try { executeStmt(db, "SELECT 1 AS checkThatTheDatabaseCanBeOpened", config.intMode); @@ -315,7 +315,7 @@ function valueToSql(value: InValue): unknown { } return value; } else if (typeof value === "boolean") { - return value ? 1 : 0; + return value; } else if (value instanceof ArrayBuffer) { return Buffer.from(value); } else if (value instanceof Date) { From f3e1d2a5340244cffe137cf08a0f95fb37415624 Mon Sep 17 00:00:00 2001 From: Hebilicious Date: Mon, 28 Aug 2023 18:05:43 +0700 Subject: [PATCH 10/24] refactor: rename index to node --- package.json | 10 +++++----- src/__tests__/client.test.ts | 12 +++++------- src/bun.ts | 2 ++ src/bun_sqlite.ts | 2 ++ src/bun_tests/bun.client.test.ts | 4 ++-- src/{index.ts => node.ts} | 5 ----- 6 files changed, 16 insertions(+), 19 deletions(-) rename src/{index.ts => node.ts} (79%) diff --git a/package.json b/package.json index 7b919469..d520297e 100644 --- a/package.json +++ b/package.json @@ -22,20 +22,20 @@ ], "license": "MIT", "type": "module", - "main": "lib-cjs/index.js", - "types": "lib-esm/index.d.ts", + "main": "lib-cjs/node.js", + "types": "lib-esm/node.d.ts", "exports": { ".": { - "types": "./lib-esm/index.d.ts", + "types": "./lib-esm/node.d.ts", "import": { "workerd": "./lib-esm/web.js", "deno": "./lib-esm/web.js", "edge-light": "./lib-esm/web.js", "netlify": "./lib-esm/web.js", "bun": "./lib-esm/bun.js", - "default": "./lib-esm/index.js" + "default": "./lib-esm/node.js" }, - "require": "./lib-cjs/index.js" + "require": "./lib-cjs/node.js" }, "./http": { "types": "./lib-esm/http.d.ts", diff --git a/src/__tests__/client.test.ts b/src/__tests__/client.test.ts index 3c2e5a5a..247800e3 100644 --- a/src/__tests__/client.test.ts +++ b/src/__tests__/client.test.ts @@ -1,14 +1,12 @@ -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"; import "./helpers.js"; -import type * as libsql from ".."; -import { createClient } from ".."; +import type * as libsql from "../node.js"; +import { createClient } from "../node.js"; const config = { url: process.env.URL ?? "ws://localhost:8080", @@ -438,7 +436,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 () => { @@ -484,7 +482,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}`); } @@ -498,7 +496,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/bun.ts b/src/bun.ts index ab447b13..e3876db1 100644 --- a/src/bun.ts +++ b/src/bun.ts @@ -16,6 +16,8 @@ export function createClient(config: Config): Client { /** @private */ export function _createClient(config: ExpandedConfig): Client { + const isBun = !!(globalThis as any).Bun || !!(globalThis as any).process?.versions?.bun; + if (!isBun) throw new LibsqlError("Bun is not available", "BUN_NOT_AVAILABLE"); if (config.scheme === "ws" || config.scheme === "wss") { return _createWsClient(config); } else if (config.scheme === "http" || config.scheme === "https") { diff --git a/src/bun_sqlite.ts b/src/bun_sqlite.ts index dc77d4df..8590286e 100644 --- a/src/bun_sqlite.ts +++ b/src/bun_sqlite.ts @@ -35,6 +35,8 @@ export function createClient(config: Config): Client { /** @private */ export function _createClient(config: ExpandedConfig): Client { + const isBun = !!(globalThis as any).Bun || !!(globalThis as any).process?.versions?.bun; + if (!isBun) throw new LibsqlError("Bun is not available", "BUN_NOT_AVAILABLE"); if (config.scheme !== "file") { throw new LibsqlError( `URL scheme ${JSON.stringify( diff --git a/src/bun_tests/bun.client.test.ts b/src/bun_tests/bun.client.test.ts index 8a1c4522..7f9ed911 100644 --- a/src/bun_tests/bun.client.test.ts +++ b/src/bun_tests/bun.client.test.ts @@ -3,8 +3,8 @@ 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 ".."; -import { LibsqlError, createClient } from ".."; +import type * as libsql from "../bun"; +import { LibsqlError, createClient } from "../bun"; const config = { url: process.env.URL ?? "file:///tmp/test.db" ?? "ws://localhost:8080", diff --git a/src/index.ts b/src/node.ts similarity index 79% rename from src/index.ts rename to src/node.ts index 8759abb5..4ba096eb 100644 --- a/src/index.ts +++ b/src/node.ts @@ -1,7 +1,6 @@ import type { Config, Client } from "./api.js"; import type { ExpandedConfig } from "./config.js"; import { expandConfig } from "./config.js"; -import { _createClient as _createBunSqliteClient } from "./bun_sqlite.js"; import { _createClient as _createSqlite3Client } from "./sqlite3.js"; import { _createClient as _createWsClient } from "./ws.js"; import { _createClient as _createHttpClient } from "./http.js"; @@ -16,15 +15,11 @@ export function createClient(config: Config): Client { return _createClient(expandConfig(config, true)); } -const isBun = !!(globalThis as any).Bun || !!(globalThis as any).process?.versions?.bun; - function _createClient(config: ExpandedConfig) { if (config.scheme === "wss" || config.scheme === "ws") { return _createWsClient(config); } else if (config.scheme === "https" || config.scheme === "http") { return _createHttpClient(config); - } else if (isBun) { - return _createBunSqliteClient(config); } else { return _createSqlite3Client(config); } From 0dd1069f3242fe2f7267081ee5441bf28fde4d09 Mon Sep 17 00:00:00 2001 From: Hebilicious Date: Mon, 28 Aug 2023 18:11:34 +0700 Subject: [PATCH 11/24] test: fix imports --- src/__tests__/helpers.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/__tests__/helpers.ts b/src/__tests__/helpers.ts index d0513b12..f25f5689 100644 --- a/src/__tests__/helpers.ts +++ b/src/__tests__/helpers.ts @@ -1,7 +1,7 @@ import { expect } from "@jest/globals"; import type { MatcherFunction } from "expect"; -import { LibsqlError } from ".."; +import { LibsqlError } from "../node.js"; const toBeLibsqlError: MatcherFunction<[code?: string, message?: RegExp]> = function (actual, code?, messageRe?) { @@ -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 "); From 7d1a444c051304102147c75a7b1bed7de0300396 Mon Sep 17 00:00:00 2001 From: Hebilicious Date: Mon, 28 Aug 2023 18:17:47 +0700 Subject: [PATCH 12/24] refactor: bun tests helpers --- src/bun_tests/bun.client.test.ts | 38 ++------------------------------ src/bun_tests/bun.helpers.ts | 38 ++++++++++++++++++++++++++++++++ src/bun_tests/bun.uri.test.ts | 4 ++-- 3 files changed, 42 insertions(+), 38 deletions(-) create mode 100644 src/bun_tests/bun.helpers.ts diff --git a/src/bun_tests/bun.client.test.ts b/src/bun_tests/bun.client.test.ts index 7f9ed911..ac8603c1 100644 --- a/src/bun_tests/bun.client.test.ts +++ b/src/bun_tests/bun.client.test.ts @@ -4,7 +4,8 @@ import type { Request, Response } from "@libsql/hrana-client"; import { fetch } from "@libsql/hrana-client"; import type * as libsql from "../bun"; -import { LibsqlError, createClient } 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", @@ -25,41 +26,6 @@ function withClient( }; } -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); - } - } -}; - -const expectBunSqliteError = 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(withPattern("BUN_SQLITE ERROR", pattern)); - } - } -}; - describe("createClient()", () => { test("URL scheme not supported", () => { expectLibSqlError( diff --git a/src/bun_tests/bun.helpers.ts b/src/bun_tests/bun.helpers.ts new file mode 100644 index 00000000..97daeb34 --- /dev/null +++ b/src/bun_tests/bun.helpers.ts @@ -0,0 +1,38 @@ +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) => { + try { + await f(); + } catch (e: any) { + expect(e).toBeInstanceOf(LibsqlError); + expect(e.code.length).toBeGreaterThan(0); + if (pattern !== undefined) { + expect(e.message).toMatch(withPattern("BUN_SQLITE ERROR", pattern)); + } + } +}; diff --git a/src/bun_tests/bun.uri.test.ts b/src/bun_tests/bun.uri.test.ts index ee4669df..ad96ab82 100644 --- a/src/bun_tests/bun.uri.test.ts +++ b/src/bun_tests/bun.uri.test.ts @@ -1,6 +1,6 @@ import { describe, test, expect } from "bun:test"; -import { parseUri, encodeBaseUrl } from "../uri.js"; -import { expectLibSqlError, withPattern } from "./bun.client.test.js"; +import { parseUri, encodeBaseUrl } from "../uri"; +import { expectLibSqlError, withPattern } from "./bun.helpers"; describe("parseUri()", () => { test("authority and path", () => { From 86306aec6926e730c53392d5a4530bbe7327060b Mon Sep 17 00:00:00 2001 From: Hebilicious Date: Mon, 28 Aug 2023 18:21:19 +0700 Subject: [PATCH 13/24] feat: batch statements according to line breaks --- src/bun_sqlite.ts | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/src/bun_sqlite.ts b/src/bun_sqlite.ts index 8590286e..8cc6f003 100644 --- a/src/bun_sqlite.ts +++ b/src/bun_sqlite.ts @@ -141,11 +141,7 @@ export class BunSqliteClient implements Client { async executeMultiple(sql: string): Promise { this.#checkNotClosed(); - const stmts = sql - .split(";") - .map((s) => s.trim()) - .filter(Boolean); - await this.batch(stmts); + await this.batch(batchStatementsFromSql(sql)); } close(): void { @@ -185,11 +181,7 @@ export class BunSqliteTransaction implements Transaction { async executeMultiple(sql: string): Promise { this.#checkNotClosed(); - const stmts = sql - .split(";") - .map((s) => s.trim()) - .filter(Boolean); - await this.batch(stmts); + await this.batch(batchStatementsFromSql(sql)); } async rollback(): Promise { @@ -262,6 +254,12 @@ function executeStmt(db: Database, stmt: InStatement, intMode: IntMode): ResultS } } +const batchStatementsFromSql = (sql: string) => + sql + .split("\n") + .map((s) => s.trim()) + .filter(Boolean); + function convertSqlResultToRows(results: Record[], intMode: IntMode): Row[] { return results.map((result) => { const entries = Object.entries(result); From de510922934ad984b90917a773f860e73ef2af8d Mon Sep 17 00:00:00 2001 From: Hebilicious Date: Mon, 28 Aug 2023 18:25:28 +0700 Subject: [PATCH 14/24] chore: test comments --- src/bun_tests/bun.client.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/bun_tests/bun.client.test.ts b/src/bun_tests/bun.client.test.ts index ac8603c1..6e59ab42 100644 --- a/src/bun_tests/bun.client.test.ts +++ b/src/bun_tests/bun.client.test.ts @@ -1004,7 +1004,7 @@ describe("executeMultiple()", () => { }) ); - //@note bun implementation use batch doesn't support manual transactions + //@note bun implementation uses batch and doesn't support manual transactions test.skip( "manual transaction control statements", withClient(async (c) => { @@ -1022,6 +1022,7 @@ describe("executeMultiple()", () => { }) ); + //@note bun implementation uses batch and doesn't support manual transactions test.skip( "error rolls back a manual transaction", withClient(async (c) => { From 0ac69f490c2fdf8bf1e5f784bd37cb384e345844 Mon Sep 17 00:00:00 2001 From: Hebilicious Date: Mon, 28 Aug 2023 18:29:14 +0700 Subject: [PATCH 15/24] chore: fix node import map --- package.json | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/package.json b/package.json index d520297e..ae69270c 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,11 @@ }, "require": "./lib-cjs/node.js" }, + "./node": { + "types": "./lib-esm/node.d.ts", + "import": "./lib-esm/node.js", + "require": "./lib-cjs/node.js" + }, "./http": { "types": "./lib-esm/http.d.ts", "import": "./lib-esm/http.js", @@ -57,10 +62,15 @@ "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" + "types": "./lib-esm/bun_sqlite.d.ts", + "import": "./lib-esm/bun_sqlite.js", + "require": "./lib-cjs/bun_sqlite.js" } }, "typesVersions": { @@ -81,7 +91,7 @@ "./lib-esm/bun.d.ts" ], "bun-sqlite": [ - "./lib-esm/bun-sqlite.d.ts" + "./lib-esm/bun_sqlite.d.ts" ] } }, From 5943e3772c2149bdc31590f53d3fe61b865e2196 Mon Sep 17 00:00:00 2001 From: Hebilicious Date: Mon, 28 Aug 2023 18:32:47 +0700 Subject: [PATCH 16/24] chore: specify node export --- package.json | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/package.json b/package.json index ae69270c..4ec7cea2 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,7 @@ "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" }, "require": "./lib-cjs/node.js" @@ -75,6 +76,9 @@ }, "typesVersions": { "*": { + ".": [ + "./lib-esm/node.d.ts" + ], "http": [ "./lib-esm/http.d.ts" ], From 20df841293eadf3ace3b6fba9486fcef40efb173 Mon Sep 17 00:00:00 2001 From: Hebilicious Date: Wed, 30 Aug 2023 00:30:22 +0700 Subject: [PATCH 17/24] chore: add prettier config file --- .prettierrc | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .prettierrc diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 00000000..5d68ead5 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,5 @@ +{ + "semi": true, + "printWidth": 130, + "tabWidth": 4 +} From 3125b8826ecab9b1bd4489112f5dc89e37ed69d1 Mon Sep 17 00:00:00 2001 From: Hebilicious Date: Wed, 30 Aug 2023 00:32:49 +0700 Subject: [PATCH 18/24] chore: add jest types to tsconfig --- tsconfig.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tsconfig.json b/tsconfig.json index 2e2f39c1..c52bc3f4 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -3,6 +3,6 @@ "compilerOptions": { "incremental": true, "noEmit": true, - "types": ["bun-types"] + "types": ["bun-types", "@types/jest"] } } From 4a9d9ab13080566bd01a7f851eeade282015c431 Mon Sep 17 00:00:00 2001 From: Hebilicious Date: Wed, 30 Aug 2023 00:33:51 +0700 Subject: [PATCH 19/24] refactor: extract common utilities --- src/bun_sqlite.ts | 70 +++++----------------------------- src/sqlite3.ts | 95 ++++++++++++++--------------------------------- src/util.ts | 75 +++++++++++++++++++++++++++++++------ 3 files changed, 99 insertions(+), 141 deletions(-) diff --git a/src/bun_sqlite.ts b/src/bun_sqlite.ts index 8cc6f003..1638289f 100644 --- a/src/bun_sqlite.ts +++ b/src/bun_sqlite.ts @@ -14,12 +14,11 @@ import type { Row, Value, InValue, - InStatement + 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 } from "./util.js"; export * from "./api.js"; @@ -37,36 +36,7 @@ export function createClient(config: Config): Client { export function _createClient(config: ExpandedConfig): Client { const isBun = !!(globalThis as any).Bun || !!(globalThis as any).process?.versions?.bun; if (!isBun) throw new LibsqlError("Bun is not available", "BUN_NOT_AVAILABLE"); - 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"); - } - } + validateFileConfig(config); const path = config.path; const options = undefined; //@todo implement options @@ -106,10 +76,7 @@ export class BunSqliteClient implements Client { } } - async batch( - stmts: Array, - mode: TransactionMode = "deferred" - ): Promise> { + async batch(stmts: Array, mode: TransactionMode = "deferred"): Promise> { this.#checkNotClosed(); const db = new Database(this.#path, this.#options); try { @@ -216,22 +183,7 @@ export class BunSqliteTransaction implements Transaction { } function executeStmt(db: 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) { - args[name] = valueToSql(stmt.args[name]); - } - } - } + const { sql, args } = parseStatement(stmt, valueToSql); try { const sqlStmt = db.prepare(sql); @@ -283,9 +235,7 @@ function valueFromSql(sqlValue: unknown, intMode: IntMode): Value { if (typeof sqlValue === "number") { 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") { @@ -301,7 +251,7 @@ function valueFromSql(sqlValue: unknown, intMode: IntMode): Value { return sqlValue as Value; } -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"); @@ -309,9 +259,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") { diff --git a/src/sqlite3.ts b/src/sqlite3.ts index c5e32b46..80063fe1 100644 --- a/src/sqlite3.ts +++ b/src/sqlite3.ts @@ -2,50 +2,35 @@ 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 } from "./util.js"; export * from "./api.js"; +const minInteger = -9223372036854775808n; +const maxInteger = 9223372036854775807n; +const minSafeBigint = -9007199254740991n; +const maxSafeBigint = 9007199254740991n; + export function createClient(config: Config): Client { return _createClient(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"); - } - } + validateFileConfig(config); const path = config.path; const options = {}; @@ -97,7 +82,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 +180,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 +196,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 +235,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 +259,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 +275,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 521b35e8..cb74a95f 100644 --- a/src/util.ts +++ b/src/util.ts @@ -1,8 +1,64 @@ 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"); + } + } +} export function transactionModeToBegin(mode: TransactionMode): string { if (mode === "write") { return "BEGIN IMMEDIATE"; @@ -21,12 +77,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 +86,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,7 +100,7 @@ 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 { From c4e45e78e63458fc5f461d773891f3c61eb3948a Mon Sep 17 00:00:00 2001 From: Hebilicious Date: Wed, 30 Aug 2023 00:46:18 +0700 Subject: [PATCH 20/24] feat: throw error on executeMultiple --- src/bun_sqlite.ts | 15 +-- src/bun_tests/bun.client.test.ts | 162 ++++++++----------------------- 2 files changed, 47 insertions(+), 130 deletions(-) diff --git a/src/bun_sqlite.ts b/src/bun_sqlite.ts index 1638289f..9ad20a31 100644 --- a/src/bun_sqlite.ts +++ b/src/bun_sqlite.ts @@ -107,8 +107,7 @@ export class BunSqliteClient implements Client { } async executeMultiple(sql: string): Promise { - this.#checkNotClosed(); - await this.batch(batchStatementsFromSql(sql)); + throw executeMultipleNotImplemented(); } close(): void { @@ -147,8 +146,7 @@ export class BunSqliteTransaction implements Transaction { } async executeMultiple(sql: string): Promise { - this.#checkNotClosed(); - await this.batch(batchStatementsFromSql(sql)); + throw executeMultipleNotImplemented(); } async rollback(): Promise { @@ -206,12 +204,6 @@ function executeStmt(db: Database, stmt: InStatement, intMode: IntMode): ResultS } } -const batchStatementsFromSql = (sql: string) => - sql - .split("\n") - .map((s) => s.trim()) - .filter(Boolean); - function convertSqlResultToRows(results: Record[], intMode: IntMode): Row[] { return results.map((result) => { const entries = Object.entries(result); @@ -284,3 +276,6 @@ function mapSqliteError(e: unknown): unknown { } return e; } + +const executeMultipleNotImplemented = () => + new LibsqlError("bun:sqlite doesn't support executeMultiple. Use batch instead.", "BUN_SQLITE ERROR"); diff --git a/src/bun_tests/bun.client.test.ts b/src/bun_tests/bun.client.test.ts index 6e59ab42..81d093bd 100644 --- a/src/bun_tests/bun.client.test.ts +++ b/src/bun_tests/bun.client.test.ts @@ -9,13 +9,10 @@ import { expectBunSqliteError, expectLibSqlError, withPattern } from "./bun.help const config = { url: process.env.URL ?? "file:///tmp/test.db" ?? "ws://localhost:8080", - authToken: process.env.AUTH_TOKEN + authToken: process.env.AUTH_TOKEN, }; -function withClient( - f: (c: libsql.Client) => Promise, - extraConfig?: Partial -): () => Promise { +function withClient(f: (c: libsql.Client) => Promise, extraConfig?: Partial): () => Promise { return async () => { const c = createClient({ ...config, ...extraConfig }); try { @@ -28,43 +25,26 @@ function withClient( describe("createClient()", () => { test("URL scheme not supported", () => { - expectLibSqlError( - () => createClient({ url: "ftp://localhost" }), - withPattern("URL_SCHEME_NOT_SUPPORTED", /"ftp:"/) - ); + 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"/) - ); + 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" - ]; + 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/) - ); + 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"/) - ); + expectLibSqlError(() => createClient({ url: "libsql://localhost?tls=yes" }), withPattern("URL_INVALID", /"tls".*"yes"/)); }); test("passing URL instead of config object", () => { @@ -103,7 +83,7 @@ describe("execute()", () => { expect(Object.entries(r)).toStrictEqual([ ["one", 1], ["two", "two"], - ["three", 0.5] + ["three", 0.5], ]); }) ); @@ -141,11 +121,7 @@ describe("execute()", () => { "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)" - ], + ["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"); @@ -157,19 +133,12 @@ describe("execute()", () => { 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" - ); + 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!] + args: [insertRs.lastInsertRowid!], }); expect(Array.from(selectRs.rows[0])).toStrictEqual(["three"]); }) @@ -190,10 +159,7 @@ describe("execute()", () => { 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" - ); + 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) @@ -205,12 +171,7 @@ describe("execute()", () => { }); describe("values", () => { - function testRoundtrip( - name: string, - passed: libsql.InValue, - expected: libsql.Value, - intMode?: libsql.IntMode - ): void { + function testRoundtrip(name: string, passed: libsql.InValue, expected: libsql.Value, intMode?: libsql.IntMode): void { test( name, withClient( @@ -223,12 +184,7 @@ describe("values", () => { ); } - function testRoundtripError( - name: string, - passed: libsql.InValue, - expectedError: unknown, - intMode?: libsql.IntMode - ): void { + function testRoundtripError(name: string, passed: libsql.InValue, expectedError: unknown, intMode?: libsql.IntMode): void { test( name, withClient( @@ -236,7 +192,7 @@ describe("values", () => { await expect( c.execute({ sql: "SELECT ?", - args: [passed] + args: [passed], }) ).rejects.toBeInstanceOf(expectedError); }, @@ -247,11 +203,7 @@ describe("values", () => { 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("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); @@ -272,12 +224,7 @@ describe("values", () => { // 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" - ); + testRoundtrip("largest negative integer", -9223372036854775808n, -9223372036854775808n, "bigint"); }); describe("'string' int mode", () => { @@ -362,7 +309,7 @@ describe("ResultSet.toJSON()", () => { columns: [], rows: [], rowsAffected: 1, - lastInsertRowid: "0" //@note not implemented with bun + lastInsertRowid: "0", //@note not implemented with bun }); }) ); @@ -370,9 +317,7 @@ describe("ResultSet.toJSON()", () => { 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 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"]]); @@ -399,7 +344,7 @@ describe("arguments", () => { withClient(async (c) => { const rs = await c.execute({ sql: "SELECT ?1, ?2", - args: ["one", "two"] + args: ["one", "two"], }); expect(Array.from(rs.rows[0])).toStrictEqual(["one", "two"]); }) @@ -410,7 +355,7 @@ describe("arguments", () => { withClient(async (c) => { const rs = await c.execute({ sql: "SELECT ?2, ?3, ?1", - args: ["one", "two", "three"] + args: ["one", "two", "three"], }); expect(Array.from(rs.rows[0])).toStrictEqual(["two", "three", "one"]); }) @@ -421,7 +366,7 @@ describe("arguments", () => { withClient(async (c) => { const rs = await c.execute({ sql: "SELECT ?3, ?1", - args: ["one", "two", "three"] + args: ["one", "two", "three"], }); expect(Array.from(rs.rows[0])).toStrictEqual(["three", "one"]); }) @@ -433,7 +378,7 @@ describe("arguments", () => { withClient(async (c) => { const rs = await c.execute({ sql: "SELECT ?2, ?, ?3", - args: ["one", "two", "three"] + args: ["one", "two", "three"], }); expect(Array.from(rs.rows[0])).toStrictEqual(["two", "three", "three"]); }) @@ -445,7 +390,7 @@ describe("arguments", () => { withClient(async (c) => { const rs = await c.execute({ sql: `SELECT ${sign}b, ${sign}a`, - args: { [`${sign}a`]: "one", [`${sign}b`]: "two" } + args: { [`${sign}a`]: "one", [`${sign}b`]: "two" }, }); expect(Array.from(rs.rows[0])).toStrictEqual(["two", "one"]); }) @@ -456,7 +401,7 @@ describe("arguments", () => { 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" } + args: { [`${sign}a`]: "one", [`${sign}b`]: "two" }, }); expect(Array.from(rs.rows[0])).toStrictEqual(["two", "one", "twoone"]); }) @@ -468,7 +413,7 @@ describe("arguments", () => { withClient(async (c) => { const rs = await c.execute({ sql: `SELECT ${sign}b, ${sign}a, ?1`, - args: { [`${sign}a`]: "one", [`${sign}b`]: "two" } + args: { [`${sign}a`]: "one", [`${sign}b`]: "two" }, }); expect(Array.from(rs.rows[0])).toStrictEqual(["two", "one", "two"]); }) @@ -485,7 +430,7 @@ describe("batch()", () => { "SELECT 1+1", "SELECT 1 AS one, 2 AS two", { sql: "SELECT ?", args: ["boomerang"] }, - { sql: "VALUES (?), (?)", args: ["big", "ben"] } + { sql: "VALUES (?), (?)", args: ["big", "ben"] }, ], "read" ); @@ -519,7 +464,7 @@ describe("batch()", () => { /* 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" + /* 6 */ "DROP TABLE t", ], "write" ); @@ -528,7 +473,7 @@ describe("batch()", () => { expect(rss[3].rows).toEqual([{ a: 1, b: "one" }]); expect(rss[5].rows).toEqual([ { a: 1, b: "one" }, - { a: 2, b: "two" } + { a: 2, b: "two" }, ]); }) ); @@ -537,12 +482,7 @@ describe("batch()", () => { "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)" - ], + ["DROP TABLE IF EXISTS t1", "DROP TABLE IF EXISTS t2", "CREATE TABLE t1 (a)", "CREATE TABLE t2 (a)"], "write" ); @@ -557,7 +497,7 @@ describe("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" + "SELECT SUM(a) FROM t2", ], "write" ); @@ -592,10 +532,7 @@ describe("batch()", () => { 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" - ) + c.batch(["INSERT INTO t VALUES ('two')", "SELECT foobar", "INSERT INTO t VALUES ('three')"], "write") ); const rs = await c.execute("SELECT COUNT(*) FROM t"); @@ -645,12 +582,7 @@ describe("batch()", () => { "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" - ], + ["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); @@ -671,10 +603,7 @@ describe("batch()", () => { 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" - ) + 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"); @@ -866,7 +795,7 @@ describe("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] } + { sql: "INSERT INTO t VALUES (?)", args: [4] }, ]); const rs = await txn.execute("SELECT SUM(a) FROM t"); @@ -885,7 +814,7 @@ describe("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] } + { sql: "INSERT INTO t VALUES (?)", args: [4] }, ]); const rs = await txn.execute("SELECT SUM(a) FROM t"); @@ -905,7 +834,7 @@ describe("batch()", () => { "CREATE TABLE t (a UNIQUE)", "INSERT INTO t VALUES (1), (2), (4)", "INSERT INTO t VALUES (1)", - "INSERT INTO t VALUES (8), (16)" + "INSERT INTO t VALUES (8), (16)", ]) ); const rs = await txn.execute("SELECT SUM(a) FROM t"); @@ -916,7 +845,8 @@ describe("batch()", () => { ); }); -describe("executeMultiple()", () => { +//@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) => { @@ -969,9 +899,7 @@ describe("executeMultiple()", () => { await txn.commit(); }) ); -}); -describe("executeMultiple()", () => { test( "multiple statements", withClient(async (c) => { @@ -980,7 +908,6 @@ describe("executeMultiple()", () => { 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); }) @@ -998,14 +925,11 @@ describe("executeMultiple()", () => { INSERT INTO t VALUES (100), (1000);`) ); const rs = await c.execute("SELECT SUM(a) FROM t"); - const rs2 = await c.execute("SELECT * from t"); - //@note bun implementation use batch expect(rs.rows[0][0]).toStrictEqual(15); }) ); - //@note bun implementation uses batch and doesn't support manual transactions - test.skip( + test( "manual transaction control statements", withClient(async (c) => { await c.executeMultiple(` @@ -1022,8 +946,7 @@ describe("executeMultiple()", () => { }) ); - //@note bun implementation uses batch and doesn't support manual transactions - test.skip( + test( "error rolls back a manual transaction", withClient(async (c) => { await expect( @@ -1039,7 +962,6 @@ describe("executeMultiple()", () => { `) ).toThrow(); // .rejects.toBeLibsqlError(); - const rs = await c.execute("SELECT SUM(a) FROM t"); expect(rs.rows[0][0]).toStrictEqual(0); }) @@ -1050,7 +972,7 @@ describe("executeMultiple()", () => { describe.skip("network errors", () => { const testCases = [ { title: "WebSocket close", sql: ".close_ws" }, - { title: "TCP close", sql: ".close_tcp" } + { title: "TCP close", sql: ".close_tcp" }, ]; for (const { title, sql } of testCases) { From b24e43c814107ec89c45fdae3e881839716abc70 Mon Sep 17 00:00:00 2001 From: Hebilicious Date: Wed, 30 Aug 2023 01:35:06 +0700 Subject: [PATCH 21/24] feat: bunsqlite only support number intMode --- src/bun_sqlite.ts | 19 +++++++---- src/bun_tests/bun.client.test.ts | 56 ++++++++++++++++++-------------- 2 files changed, 44 insertions(+), 31 deletions(-) diff --git a/src/bun_sqlite.ts b/src/bun_sqlite.ts index 9ad20a31..417718b8 100644 --- a/src/bun_sqlite.ts +++ b/src/bun_sqlite.ts @@ -22,7 +22,6 @@ import { transactionModeToBegin, ResultSetImpl, validateFileConfig, parseStateme export * from "./api.js"; -/** https://github.com/oven-sh/bun/issues/1536 */ const minInteger = -9223372036854775808n; const maxInteger = 9223372036854775807n; const minSafeBigint = -9007199254740991n; @@ -36,6 +35,8 @@ export function createClient(config: Config): Client { export function _createClient(config: ExpandedConfig): Client { const isBun = !!(globalThis as any).Bun || !!(globalThis as any).process?.versions?.bun; if (!isBun) throw new LibsqlError("Bun is not available", "BUN_NOT_AVAILABLE"); + // bigint handling https://github.com/oven-sh/bun/issues/1536 + if (config.intMode !== "number") throw intModeNotImplemented(config.intMode); validateFileConfig(config); const path = config.path; @@ -182,7 +183,6 @@ export class BunSqliteTransaction implements Transaction { 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[]; @@ -224,16 +224,16 @@ function convertSqlResultToRows(results: Record[], intMode: IntMo function valueFromSql(sqlValue: unknown, intMode: IntMode): Value { // https://github.com/oven-sh/bun/issues/1536 - if (typeof sqlValue === "number") { + 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 BigInt(sqlValue); + return sqlValue; } else if (intMode === "string") { - return "" + sqlValue; + return String(sqlValue); } else { throw new Error("Invalid value for IntMode"); } @@ -267,15 +267,20 @@ function valueToSql(value: InValue) { } } +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 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"); + 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 index 81d093bd..ea1c729e 100644 --- a/src/bun_tests/bun.client.test.ts +++ b/src/bun_tests/bun.client.test.ts @@ -184,6 +184,19 @@ describe("values", () => { ); } + 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, @@ -213,33 +226,28 @@ describe("values", () => { testRoundtrip("zero integer", 0n, 0, "number"); testRoundtrip("small integer", -42n, -42, "number"); testRoundtrip("largest safe integer", 9007199254740991n, 9007199254740991, "number"); - testRoundtripError("smallest unsafe integer", 9007199254740992n, RangeError, "number"); - testRoundtripError("large unsafe integer", -1152921504594532842n, RangeError, "number"); + testDifference("smallest unsafe positive integer", 9007199254740992n, "number"); + testDifference("large unsafe negative integer", -1152921504594532842n, "number"); }); - describe("'bigint' int mode", () => { + //@note not implemented with bun:sqlite + describe.skip("'bigint' int mode", () => { testRoundtrip("zero integer", 0n, 0n, "bigint"); testRoundtrip("small integer", -42n, -42n, "bigint"); - //@note Lack of proper BigInt support // https://github.com/oven-sh/bun/issues/1536 - // testRoundtrip("large positive integer", 1152921504608088318n, 1152921504608088318n, "bigint"); - // testRoundtrip("large negative integer", -1152921504594532842n, -1152921504594532842n, "bigint"); - // testRoundtrip("largest positive integer", 9223372036854775807n, 9223372036854775807n, "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"); }); - describe("'string' int mode", () => { + //@note not implemented with bun:sqlite + describe.skip("'string' int mode", () => { testRoundtrip("zero integer", 0n, "0", "string"); testRoundtrip("small integer", -42n, "-42", "string"); - //@note Lack of proper BigInt support // https://github.com/oven-sh/bun/issues/1536 - // 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" - // ); + 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); @@ -324,7 +332,8 @@ describe("ResultSet.toJSON()", () => { }) ); - test( + //@note not implemented with bun:sqlite + test.skip( "bigint row value", withClient( async (c) => { @@ -338,8 +347,7 @@ describe("ResultSet.toJSON()", () => { }); describe("arguments", () => { - //@note not supported by bun:sqlite - test.skip( + test( "? arguments", withClient(async (c) => { const rs = await c.execute({ @@ -373,7 +381,7 @@ describe("arguments", () => { ); //@note not supported by bun:sqlite - test.skip( + test( "?NNN and ? arguments", withClient(async (c) => { const rs = await c.execute({ @@ -407,8 +415,7 @@ describe("arguments", () => { }) ); - //@note not supported by bun:sqlite - test.skip( + test( `${sign}AAAA arguments and ?NNN arguments`, withClient(async (c) => { const rs = await c.execute({ @@ -1012,6 +1019,7 @@ describe.skip("network errors", () => { } }); +//@note bun implementation is tested locally. test.skip("custom fetch", async () => { let fetchCalledCount = 0; function customFetch(request: Request): Promise { From 816163f74997451806f8d248bfdcce19b9d77db5 Mon Sep 17 00:00:00 2001 From: Hebilicious Date: Wed, 30 Aug 2023 02:16:40 +0700 Subject: [PATCH 22/24] refactor: extract integer values --- src/bun_sqlite.ts | 21 ++++++++++++--------- src/sqlite3.ts | 16 ++++++++++------ src/util.ts | 5 +++++ 3 files changed, 27 insertions(+), 15 deletions(-) diff --git a/src/bun_sqlite.ts b/src/bun_sqlite.ts index 417718b8..3b3c54c2 100644 --- a/src/bun_sqlite.ts +++ b/src/bun_sqlite.ts @@ -1,9 +1,5 @@ // @ts-ignore bun:sqlite is not typed when building import { Database, SQLQueryBindings } from "bun:sqlite"; - -type ConstructorParameters = T extends new (...args: infer P) => any ? P : never; -type DatabaseOptions = ConstructorParameters[1]; - import type { Config, IntMode, @@ -18,14 +14,21 @@ import type { } from "./api.js"; import { LibsqlError } from "./api.js"; import { expandConfig, type ExpandedConfig } from "./config.js"; -import { transactionModeToBegin, ResultSetImpl, validateFileConfig, parseStatement } from "./util.js"; +import { + transactionModeToBegin, + ResultSetImpl, + validateFileConfig, + parseStatement, + minSafeBigint, + maxSafeBigint, + minInteger, + maxInteger, +} from "./util.js"; export * from "./api.js"; -const minInteger = -9223372036854775808n; -const maxInteger = 9223372036854775807n; -const minSafeBigint = -9007199254740991n; -const maxSafeBigint = 9007199254740991n; +type ConstructorParameters = T extends new (...args: infer P) => any ? P : never; +type DatabaseOptions = ConstructorParameters[1]; export function createClient(config: Config): Client { return _createClient(expandConfig(config, true)); diff --git a/src/sqlite3.ts b/src/sqlite3.ts index 80063fe1..d10eb925 100644 --- a/src/sqlite3.ts +++ b/src/sqlite3.ts @@ -15,15 +15,19 @@ import type { } from "./api.js"; import { LibsqlError } from "./api.js"; import { expandConfig, type ExpandedConfig } from "./config.js"; -import { transactionModeToBegin, ResultSetImpl, validateFileConfig, parseStatement } from "./util.js"; +import { + transactionModeToBegin, + ResultSetImpl, + validateFileConfig, + parseStatement, + minSafeBigint, + maxSafeBigint, + minInteger, + maxInteger, +} from "./util.js"; export * from "./api.js"; -const minInteger = -9223372036854775808n; -const maxInteger = 9223372036854775807n; -const minSafeBigint = -9007199254740991n; -const maxSafeBigint = 9007199254740991n; - export function createClient(config: Config): Client { return _createClient(expandConfig(config, true)); } diff --git a/src/util.ts b/src/util.ts index cb74a95f..ddfdbafe 100644 --- a/src/util.ts +++ b/src/util.ts @@ -107,3 +107,8 @@ function valueToJson(value: Value): unknown { return value; } } + +export const minInteger = -9223372036854775808n; +export const maxInteger = 9223372036854775807n; +export const minSafeBigint = -9007199254740991n; +export const maxSafeBigint = 9007199254740991n; From 0c7b82760ad1086c56dc13c810f73eb87e4ad738 Mon Sep 17 00:00:00 2001 From: Hebilicious Date: Wed, 30 Aug 2023 02:34:31 +0700 Subject: [PATCH 23/24] refactor: validate file config --- src/bun.ts | 12 +++++++----- src/bun_sqlite.ts | 15 +++++++-------- src/sqlite3.ts | 4 +--- src/util.ts | 1 + 4 files changed, 16 insertions(+), 16 deletions(-) diff --git a/src/bun.ts b/src/bun.ts index e3876db1..cd5f1513 100644 --- a/src/bun.ts +++ b/src/bun.ts @@ -14,10 +14,14 @@ 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 { - const isBun = !!(globalThis as any).Bun || !!(globalThis as any).process?.versions?.bun; - if (!isBun) throw new LibsqlError("Bun is not available", "BUN_NOT_AVAILABLE"); + isBun(); if (config.scheme === "ws" || config.scheme === "wss") { return _createWsClient(config); } else if (config.scheme === "http" || config.scheme === "https") { @@ -27,9 +31,7 @@ export function _createClient(config: ExpandedConfig): Client { } 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}`, + `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 index 3b3c54c2..8489893b 100644 --- a/src/bun_sqlite.ts +++ b/src/bun_sqlite.ts @@ -24,24 +24,23 @@ import { 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 { - return _createClient(expandConfig(config, true)); +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 isBun = !!(globalThis as any).Bun || !!(globalThis as any).process?.versions?.bun; - if (!isBun) throw new LibsqlError("Bun is not available", "BUN_NOT_AVAILABLE"); - // bigint handling https://github.com/oven-sh/bun/issues/1536 - if (config.intMode !== "number") throw intModeNotImplemented(config.intMode); - validateFileConfig(config); - const path = config.path; const options = undefined; //@todo implement options const db = new Database(path); diff --git a/src/sqlite3.ts b/src/sqlite3.ts index d10eb925..fdefade6 100644 --- a/src/sqlite3.ts +++ b/src/sqlite3.ts @@ -29,13 +29,11 @@ import { 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 { - validateFileConfig(config); - const path = config.path; const options = {}; diff --git a/src/util.ts b/src/util.ts index ddfdbafe..fbc29dcf 100644 --- a/src/util.ts +++ b/src/util.ts @@ -58,6 +58,7 @@ export function validateFileConfig(config: ExpandedConfig) { throw new LibsqlError("File URL cannot have username and password", "URL_INVALID"); } } + return config; } export function transactionModeToBegin(mode: TransactionMode): string { if (mode === "write") { From 2a06b1e8caeaec776777c3c28f106538450ad5cb Mon Sep 17 00:00:00 2001 From: Hebilicious Date: Wed, 30 Aug 2023 02:42:42 +0700 Subject: [PATCH 24/24] test: fix expectBunSqliteError --- src/bun_tests/bun.client.test.ts | 4 ++-- src/bun_tests/bun.helpers.ts | 13 ++----------- 2 files changed, 4 insertions(+), 13 deletions(-) diff --git a/src/bun_tests/bun.client.test.ts b/src/bun_tests/bun.client.test.ts index ea1c729e..fe7ba243 100644 --- a/src/bun_tests/bun.client.test.ts +++ b/src/bun_tests/bun.client.test.ts @@ -744,7 +744,7 @@ describe("transaction()", () => { await prom1; await expectBunSqliteError(() => prom2); await expectLibSqlError(() => prom3, withPattern("TRANSACTION_CLOSED")); - await expectBunSqliteError(() => txn.commit()); + await expectLibSqlError(() => txn.commit(), withPattern("TRANSACTION_CLOSED")); txn.close(); const rs = await c.execute("SELECT COUNT(*) FROM t"); @@ -765,7 +765,7 @@ describe("transaction()", () => { await expectBunSqliteError(() => prom1); await expectLibSqlError(() => prom2, withPattern("TRANSACTION_CLOSED")); - await expectBunSqliteError(() => txn.commit()); + await expectLibSqlError(() => txn.commit(), withPattern("TRANSACTION_CLOSED")); txn.close(); diff --git a/src/bun_tests/bun.helpers.ts b/src/bun_tests/bun.helpers.ts index 97daeb34..2aad1974 100644 --- a/src/bun_tests/bun.helpers.ts +++ b/src/bun_tests/bun.helpers.ts @@ -25,14 +25,5 @@ export const expectLibSqlError = async (f: () => any, pattern?: string | RegExp) } }; -export const expectBunSqliteError = 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(withPattern("BUN_SQLITE ERROR", pattern)); - } - } -}; +export const expectBunSqliteError = async (f: () => any, pattern?: string | RegExp) => + expectLibSqlError(f, pattern ? withPattern("BUN_SQLITE ERROR", pattern) : withPattern("BUN_SQLITE ERROR"));