diff --git a/README.md b/README.md index e11b233..3936d38 100644 --- a/README.md +++ b/README.md @@ -21,8 +21,21 @@ console.log(version); db.close(); ``` +Using [stdext/sql](https://jsr.io/@stdext/sql) interfaces: + +```ts +import { SqliteClient } from "jsr:@db/sqlite@0.11/std_sql"; + +await using db = new SqliteClient("test.db"); + +const [version] = await db.queryArray("select sqlite_version()"); +console.log(version); +``` + ## Usage +### Permissions + Since this library depends on the unstable FFI API, you must pass `--allow-env`, `--allow-ffi` and `--unstable-ffi` flags. Network and FS permissions are also needed to download and cache prebuilt library. @@ -34,6 +47,37 @@ access. deno run -A --unstable-ffi ``` +### std/sql + +In addition to the existing `Database` class, a new entrypoint is also exported +to provide compatibility with the [stdext/sql](https://jsr.io/@stdext/sql) +interfaces. Due to the specs, this relies on promises. + +```ts +import { SqliteClient } from "jsr:@db/sqlite@0.11/std_sql"; + +await using db = new SqliteClient("test.db"); + +await db.execute("create table people (name TEXT)"); // 0 +await db.execute("insert into people (name) values ('Alex'), ('Luca');"); // 2 +await db.query("select * from people"); // [{name:"Alex"}, {name:"Luca"}] +await db.queryOne("select * from people"); // {name:"Alex"} +Array.fromAsync(db.queryMany("select * from people")); // [{name:"Alex"}, {name:"Luca"}] +await db.queryArray("select * from people"); // [["Alex"], ["Luca"]] +await db.queryOneArray("select * from people"); // ["Alex"] +Array.fromAsync(db.queryManyArray("select * from people")); // [["Alex"], ["Luca"]] +await db.sql`select * from people`; // [{name:"Alex"}, {name:"Luca"}] +await db.sqlArray`select * from people`; // [["Alex"], ["Luca"]] +``` + +> In general, the `SqliteClient` is good for most cases, and conforms to the +> generalized interfaces in the standard library. However if you are facing +> speed bottlenecks, the `Database` from the main export whould give you some +> more performance. + +For more documentation regarding the standard interface, read the +[docs](https://jsr.io/@stdext/sql) + ## Benchmark ![image](./bench/results.png) diff --git a/deno.json b/deno.json index fe83142..7d6802f 100644 --- a/deno.json +++ b/deno.json @@ -3,7 +3,10 @@ "version": "0.11.2", "github": "https://github.com/denodrivers/sqlite3", - "exports": "./mod.ts", + "exports": { + ".": "./mod.ts", + "./std_sql": "./std_sql.ts" + }, "exclude": [ "sqlite", @@ -11,7 +14,8 @@ ], "tasks": { - "test": "deno test --unstable-ffi -A test/test.ts", + "test": "DENO_SQLITE_LOCAL=1 deno test --unstable-ffi -A", + "test:remote": "deno test --unstable-ffi -A", "build": "deno run -A scripts/build.ts", "bench-deno": "deno run -A --unstable-ffi bench/bench_deno.js 50 1000000", "bench-deno-ffi": "deno run -A --unstable-ffi bench/bench_deno_ffi.js 50 1000000", @@ -45,5 +49,14 @@ "explicit-module-boundary-types" ] } + }, + + "imports": { + "@denosaurs/plug": "jsr:@denosaurs/plug@^1.0.5", + "@std/assert": "jsr:@std/assert@^0.221.0", + "@std/log": "jsr:@std/log@^0.223.0", + "@std/path": "jsr:@std/path@^0.217.0", + "@stdext/collections": "jsr:@stdext/collections@^0.0.5", + "@stdext/sql": "jsr:@stdext/sql@0.0.0-6" } } diff --git a/deps.ts b/deps.ts deleted file mode 100644 index f94c8c4..0000000 --- a/deps.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { fromFileUrl } from "jsr:@std/path@0.217"; -export { dlopen } from "jsr:@denosaurs/plug@1"; diff --git a/mod.ts b/mod.ts index 469ae5b..0827148 100644 --- a/mod.ts +++ b/mod.ts @@ -4,10 +4,9 @@ export { type DatabaseOpenOptions, type FunctionOptions, isComplete, - SQLITE_SOURCEID, - SQLITE_VERSION, type Transaction, } from "./src/database.ts"; +export { SQLITE_SOURCEID, SQLITE_VERSION } from "./src/ffi.ts"; export { type BlobOpenOptions, SQLBlob } from "./src/blob.ts"; export { type BindParameters, diff --git a/src/blob.ts b/src/blob.ts index b01d65d..8174f1a 100644 --- a/src/blob.ts +++ b/src/blob.ts @@ -1,6 +1,6 @@ import type { Database } from "./database.ts"; -import ffi from "./ffi.ts"; -import { toCString, unwrap } from "./util.ts"; +import ffi, { unwrap } from "./ffi.ts"; +import { toCString } from "./util.ts"; const { sqlite3_blob_open, diff --git a/src/connection.test.ts b/src/connection.test.ts new file mode 100644 index 0000000..f1daa6e --- /dev/null +++ b/src/connection.test.ts @@ -0,0 +1,28 @@ +import { assert, assertEquals } from "@std/assert"; +import { SqliteConnectable, SqliteConnection } from "./connection.ts"; +import { _testSqlConnectable, testSqlConnection } from "@stdext/sql/testing"; + +Deno.test("connection and connectable contstructs", () => { + const connection = new SqliteConnection(":memory:"); + testSqlConnection(connection, { connectionUrl: ":memory:" }); + const connectable = new SqliteConnectable(connection); + _testSqlConnectable(connectable, connection); +}); + +Deno.test("connection can connect and query", async () => { + await using connection = new SqliteConnection(":memory:"); + await connection.connect(); + assert(connection.connected, "connection should be connected"); + const executeResult = await connection.execute(`select 1 as one`); + assertEquals(executeResult, 0); + const queryManyResult = connection.queryMany(`select 1 as one`); + const queryManyResultNext1 = await queryManyResult.next(); + assertEquals(queryManyResultNext1, { done: false, value: { one: 1 } }); + const queryManyResultNext2 = await queryManyResult.next(); + assertEquals(queryManyResultNext2, { done: true, value: undefined }); + const queryManyArrayResult = connection.queryManyArray(`select 1 as one`); + const queryManyArrayResultNext1 = await queryManyArrayResult.next(); + assertEquals(queryManyArrayResultNext1, { done: false, value: [1] }); + const queryManyArrayResultNext2 = await queryManyArrayResult.next(); + assertEquals(queryManyArrayResultNext2, { done: true, value: undefined }); +}); diff --git a/src/connection.ts b/src/connection.ts new file mode 100644 index 0000000..a1144f7 --- /dev/null +++ b/src/connection.ts @@ -0,0 +1,144 @@ +// deno-lint-ignore-file require-await +import type { + ArrayRow, + Row, + SqlConnectable, + SqlConnection, + SqlConnectionOptions, +} from "@stdext/sql"; +import { fromFileUrl } from "@std/path"; +import ffi from "./ffi.ts"; +import { Database, type DatabaseOpenOptions } from "../mod.ts"; +import type { SqliteParameterType, SqliteQueryOptions } from "./core.ts"; +import { transformToAsyncGenerator } from "./util.ts"; + +/** Various options that can be configured when opening Database connection. */ +export interface SqliteConnectionOptions + extends SqlConnectionOptions, DatabaseOpenOptions { +} + +/** + * Represents a SQLx based SQLite3 database connection. + * + * Example: + * ```ts + * // Open a database from file, creates if doesn't exist. + * const db = new SqliteClient("myfile.db"); + * + * // Open an in-memory database. + * const db = new SqliteClient(":memory:"); + * + * // Open a read-only database. + * const db = new SqliteClient("myfile.db", { readonly: true }); + * + * // Or open using File URL + * const db = new SqliteClient(new URL("./myfile.db", import.meta.url)); + * ``` + */ +export class SqliteConnection implements + SqlConnection< + SqliteConnectionOptions, + SqliteParameterType, + SqliteQueryOptions + > { + readonly connectionUrl: string; + readonly options: SqliteConnectionOptions; + + /** + * The FFI SQLite methods. + */ + readonly ffi = ffi; + + _db: Database | null = null; + + get db(): Database { + if (this._db === null) { + throw new Error("Database connection is not open"); + } + return this._db; + } + + set db(value: Database | null) { + this._db = value; + } + + get connected(): boolean { + return Boolean(this._db?.open); + } + + constructor( + connectionUrl: string | URL, + options: SqliteConnectionOptions = {}, + ) { + this.connectionUrl = connectionUrl instanceof URL + ? fromFileUrl(connectionUrl) + : connectionUrl; + this.options = options; + } + + async connect(): Promise { + this.db = new Database(this.connectionUrl, this.options); + } + + async close(): Promise { + this._db?.close(); + this._db = null; + } + + execute( + sql: string, + params?: SqliteParameterType[], + _options?: SqliteQueryOptions, + ): Promise { + return Promise.resolve(this.db.exec(sql, ...(params || []))); + } + queryMany = Row>( + sql: string, + params?: SqliteParameterType[], + options?: SqliteQueryOptions, + ): AsyncGenerator { + return transformToAsyncGenerator( + this.db.prepare(sql).getMany(params, options), + ); + } + queryManyArray = ArrayRow>( + sql: string, + params?: SqliteParameterType[], + options?: SqliteQueryOptions, + ): AsyncGenerator { + return transformToAsyncGenerator( + this.db.prepare(sql).valueMany(params, options), + ); + } + + async [Symbol.asyncDispose](): Promise { + await this.close(); + } + + [Symbol.for("Deno.customInspect")](): string { + return `SQLite3.SqliteConnection { path: ${this.connectionUrl} }`; + } +} + +export class SqliteConnectable implements + SqlConnectable< + SqliteConnectionOptions, + SqliteConnection + > { + readonly connection: SqliteConnection; + readonly options: SqliteConnectionOptions; + get connected(): boolean { + return this.connection.connected; + } + + constructor( + connection: SqliteConnectable["connection"], + options: SqliteConnectable["options"] = {}, + ) { + this.connection = connection; + this.options = options; + } + [Symbol.asyncDispose](): Promise { + return this.connection.close(); + } +} diff --git a/src/core.test.ts b/src/core.test.ts new file mode 100644 index 0000000..2dd650a --- /dev/null +++ b/src/core.test.ts @@ -0,0 +1,294 @@ +import { assertEquals, assertRejects } from "@std/assert"; +import { SQLITE_VERSION } from "./ffi.ts"; +import { + SqliteClient, + type SqliteParameterType, + SqlitePreparedStatement, + SqliteTransaction, +} from "./core.ts"; +import { + testClientConnection, + testSqlClient, + testSqlPreparedStatement, + testSqlTransaction, +} from "@stdext/sql/testing"; +import { SqliteError } from "./util.ts"; +import { SqliteConnection } from "./connection.ts"; + +Deno.test("std/sql prepared statement", async () => { + const connectionUrl = ":memory:"; + const options: SqliteTransaction["options"] = {}; + const sql = "SELECT 1 as one;"; + + const connection = new SqliteConnection(connectionUrl, options); + await connection.connect(); + const preparedStatement = new SqlitePreparedStatement( + connection, + sql, + options, + ); + testSqlPreparedStatement(preparedStatement, { + connectionUrl, + options, + sql, + }); +}); + +Deno.test("std/sql transaction", async () => { + const connectionUrl = ":memory:"; + const options: SqliteTransaction["options"] = {}; + + const connection = new SqliteConnection(connectionUrl, options); + await connection.connect(); + const transaction = new SqliteTransaction(connection, options); + + testSqlTransaction(transaction, { + connectionUrl, + options, + }); +}); + +Deno.test("std/sql client", async (t) => { + const connectionUrl = ":memory:"; + const options: SqliteClient["options"] = {}; + + const client = new SqliteClient(connectionUrl, options); + + testSqlClient(client, { connectionUrl, options }); + await testClientConnection( + t, + SqliteClient, + [ + connectionUrl, + options, + ], + ); +}); + +Deno.test("std/sql queries", async (t) => { + const DB_URL = new URL("./test.db", import.meta.url); + + // Remove any existing test.db. + await Deno.remove(DB_URL).catch(() => {}); + + await t.step("open (expect error)", async () => { + const db = new SqliteClient(DB_URL, { create: false }); + await assertRejects( + async () => await db.connect(), + SqliteError, + "14:", + ); + }); + + await t.step("open (path string)", async () => { + const db = new SqliteClient("test-path.db"); + await db.connect(); + await db.close(); + Deno.removeSync("test-path.db"); + }); + + await t.step("open (readonly)", async () => { + const db = new SqliteClient(":memory:", { readonly: true }); + await db.connect(); + await db.close(); + }); + + let db!: SqliteClient; + await t.step("open (url)", async () => { + db = new SqliteClient(DB_URL, { int64: true }); + await db.connect(); + }); + + if (typeof db !== "object") throw new Error("db open failed"); + + await t.step("execute pragma", async () => { + await db.execute("pragma journal_mode = WAL"); + await db.execute("pragma synchronous = normal"); + assertEquals(await db.execute("pragma temp_store = memory"), 0); + }); + + await t.step("select version (row as array)", async () => { + const row = await db.queryOneArray<[string]>("select sqlite_version()"); + assertEquals(row, [SQLITE_VERSION]); + }); + + await t.step("select version (row as object)", async () => { + const row = await db.queryOne< + { version: string } + >("select sqlite_version() as version"); + assertEquals(row, { version: SQLITE_VERSION }); + }); + + await t.step("create table", async () => { + await db.execute(`create table test ( + integer integer, + text text not null, + double double, + blob blob not null, + nullable integer + )`); + }); + + await t.step("insert one", async () => { + const changes = await db.execute( + `insert into test (integer, text, double, blob, nullable) + values (?, ?, ?, ?, ?)`, + [ + 0, + "hello world", + 3.14, + new Uint8Array([1, 2, 3]), + null, + ], + ); + + assertEquals(changes, 1); + }); + + await t.step("delete inserted row", async () => { + await db.execute("delete from test where integer = 0"); + }); + + await t.step("last insert row id (after insert)", () => { + assertEquals(db.connection.db.lastInsertRowId, 1); + }); + + await t.step("prepared insert", async () => { + const SQL = `insert into test (integer, text, double, blob, nullable) + values (?, ?, ?, ?, ?)`; + + const rows: SqliteParameterType[][] = []; + for (let i = 0; i < 10; i++) { + rows.push([ + i, + `hello ${i}`, + 3.14, + new Uint8Array([3, 2, 1]), + null, + ]); + } + + let changes = 0; + await db.transaction(async (t) => { + for (const row of rows) { + changes += await t.execute(SQL, row) ?? 0; + } + }); + + assertEquals(changes, 10); + }); + + await t.step("query array", async () => { + const rows = await db.queryArray< + [number, string, number, Uint8Array, null] + >("select * from test where integer = 0 limit 1"); + + assertEquals(rows.length, 1); + const row = rows[0]; + assertEquals(row[0], 0); + assertEquals(row[1], "hello 0"); + assertEquals(row[2], 3.14); + assertEquals(row[3], new Uint8Array([3, 2, 1])); + assertEquals(row[4], null); + }); + + await t.step("query object", async () => { + const rows = await db.query<{ + integer: number; + text: string; + double: number; + blob: Uint8Array; + nullable: null; + }>( + "select * from test where integer != ? and text != ?", + [ + 1, + "hello world", + ], + ); + + assertEquals(rows.length, 9); + for (const row of rows) { + assertEquals(typeof row.integer, "number"); + assertEquals(row.text, `hello ${row.integer}`); + assertEquals(row.double, 3.14); + assertEquals(row.blob, new Uint8Array([3, 2, 1])); + assertEquals(row.nullable, null); + } + }); + + await t.step("query array (iter)", async () => { + const rows = []; + for await ( + const row of await db.queryManyArray< + [number, string, number, Uint8Array, null] + >("select * from test where integer = ? limit 1", [0]) + ) { + rows.push(row); + } + + assertEquals(rows.length, 1); + + const row = rows[0]; + assertEquals(row[0], 0); + assertEquals(row[1], "hello 0"); + assertEquals(row[2], 3.14); + assertEquals(row[3], new Uint8Array([3, 2, 1])); + assertEquals(row[4], null); + }); + + await t.step("query object (iter)", async () => { + const rows = []; + for await ( + const row of db.queryMany<{ + integer: number; + text: string; + double: number; + blob: Uint8Array; + nullable: null; + }>("select * from test where integer != ? and text != ?", [ + 1, + "hello world", + ]) + ) { + rows.push(row); + } + + assertEquals(rows.length, 9); + for (const row of rows) { + assertEquals(typeof row.integer, "number"); + assertEquals(row.text, `hello ${row.integer}`); + assertEquals(row.double, 3.14); + assertEquals(row.blob, new Uint8Array([3, 2, 1])); + assertEquals(row.nullable, null); + } + }); + + await t.step("tagged template object", async () => { + assertEquals(await db.sql`select 1, 2, 3`, [{ "1": 1, "2": 2, "3": 3 }]); + assertEquals( + await db.sql`select ${1} as a, ${Math.PI} as b, ${new Uint8Array([ + 1, + 2, + ])} as c`, + [ + { a: 1, b: 3.141592653589793, c: new Uint8Array([1, 2]) }, + ], + ); + + assertEquals(await db.sql`select ${"1; DROP TABLE"}`, [{ + "?": "1; DROP TABLE", + }]); + }); + + await t.step({ + name: "close", + sanitizeResources: false, + fn(): void { + db.close(); + try { + Deno.removeSync(DB_URL); + } catch (_) { /** ignore, already being used */ } + }, + }); +}); diff --git a/src/core.ts b/src/core.ts new file mode 100644 index 0000000..a497e06 --- /dev/null +++ b/src/core.ts @@ -0,0 +1,391 @@ +import type { + ArrayRow, + Row, + SqlClient, + SqlConnectionOptions, + SqlPreparable, + SqlPreparedStatement, + SqlQueriable, + SqlQueryOptions, + SqlTransaction, + SqlTransactionable, + SqlTransactionOptions, +} from "@stdext/sql"; +import { + type BindValue, + Statement, + type StatementOptions, +} from "./statement.ts"; +import type { DatabaseOpenOptions } from "../mod.ts"; +import { + SqliteCloseEvent, + SqliteConnectEvent, + SqliteEventTarget, +} from "./events.ts"; +import { + SqliteConnectable, + SqliteConnection, + type SqliteConnectionOptions, +} from "./connection.ts"; +import { SqliteTransactionError } from "./errors.ts"; +import { mergeQueryOptions, transformToAsyncGenerator } from "./util.ts"; + +export type SqliteParameterType = BindValue; + +export interface SqliteQueryOptions extends SqlQueryOptions, StatementOptions { +} + +export interface SqliteTransactionOptions extends SqlTransactionOptions { + beginTransactionOptions: { + behavior?: "DEFERRED" | "IMMEDIATE" | "EXCLUSIVE"; + }; + commitTransactionOptions: undefined; + rollbackTransactionOptions: { + savepoint?: string; + }; +} + +/** Various options that can be configured when opening Database connection. */ +export interface SqliteClientOptions + extends SqlConnectionOptions, DatabaseOpenOptions { +} + +export class SqlitePreparedStatement extends SqliteConnectable + implements + SqlPreparedStatement< + SqliteConnectionOptions, + SqliteParameterType, + SqliteQueryOptions, + SqliteConnection + > { + readonly sql: string; + declare readonly options: SqliteConnectionOptions & SqliteQueryOptions; + readonly #statement: Statement; + #deallocated = false; + + constructor( + connection: SqlitePreparedStatement["connection"], + sql: string, + options: SqlitePreparedStatement["options"] = {}, + ) { + super(connection, options); + this.sql = sql; + + this.#statement = new Statement( + this.connection.db.unsafeHandle, + this.sql, + this.options, + ); + } + get deallocated(): boolean { + return this.#deallocated; + } + + deallocate(): Promise { + this.#statement.finalize(); + this.#deallocated = true; + return Promise.resolve(); + } + + execute( + params?: SqliteParameterType[], + _options?: SqliteQueryOptions | undefined, + ): Promise { + return Promise.resolve(this.#statement.run(params)); + } + query = Row>( + params?: SqliteParameterType[], + options?: SqliteQueryOptions | undefined, + ): Promise { + return Promise.resolve(this.#statement.all(params, options)); + } + queryOne = Row>( + params?: SqliteParameterType[], + options?: SqliteQueryOptions | undefined, + ): Promise { + return Promise.resolve(this.#statement.get(params, options)); + } + queryMany = Row>( + params?: SqliteParameterType[], + options?: SqliteQueryOptions | undefined, + ): AsyncGenerator { + return transformToAsyncGenerator( + this.#statement.getMany(params, options), + ); + } + queryArray = ArrayRow>( + params?: SqliteParameterType[], + options?: SqliteQueryOptions | undefined, + ): Promise { + return Promise.resolve(this.#statement.values(params, options)); + } + queryOneArray = ArrayRow>( + params?: SqliteParameterType[], + options?: SqliteQueryOptions | undefined, + ): Promise { + return Promise.resolve(this.#statement.value(params, options)); + } + queryManyArray = ArrayRow>( + params?: SqliteParameterType[], + options?: SqliteQueryOptions | undefined, + ): AsyncGenerator { + return transformToAsyncGenerator( + this.#statement.valueMany(params, options), + ); + } + + async [Symbol.asyncDispose](): Promise { + await this.deallocate(); + await super[Symbol.asyncDispose](); + } +} + +/** + * Represents a base queriable class for SQLite3. + */ +export class SqliteQueriable extends SqliteConnectable implements + SqlQueriable< + SqliteConnectionOptions, + SqliteParameterType, + SqliteQueryOptions, + SqliteConnection + > { + declare readonly options: SqliteConnectionOptions & SqliteQueryOptions; + + constructor( + connection: SqliteQueriable["connection"], + options: SqliteQueriable["options"] = {}, + ) { + super(connection, options); + } + prepare(sql: string, options?: SqliteQueryOptions): SqlitePreparedStatement { + return new SqlitePreparedStatement( + this.connection, + sql, + mergeQueryOptions(this.options, options), + ); + } + + execute( + sql: string, + params?: SqliteParameterType[], + options?: SqliteQueryOptions | undefined, + ): Promise { + return this.prepare(sql, options).execute(params); + } + query = Row>( + sql: string, + params?: SqliteParameterType[], + options?: SqliteQueryOptions | undefined, + ): Promise { + return this.prepare(sql, options).query(params); + } + queryOne = Row>( + sql: string, + params?: SqliteParameterType[], + options?: SqliteQueryOptions | undefined, + ): Promise { + return this.prepare(sql, options).queryOne(params); + } + queryMany = Row>( + sql: string, + params?: SqliteParameterType[], + options?: SqliteQueryOptions | undefined, + ): AsyncGenerator { + return this.prepare(sql, options).queryMany(params); + } + queryArray = ArrayRow>( + sql: string, + params?: SqliteParameterType[], + options?: SqliteQueryOptions | undefined, + ): Promise { + return this.prepare(sql, options).queryArray(params); + } + queryOneArray = ArrayRow>( + sql: string, + params?: SqliteParameterType[], + options?: SqliteQueryOptions | undefined, + ): Promise { + return this.prepare(sql, options).queryOneArray(params); + } + queryManyArray = ArrayRow>( + sql: string, + params?: SqliteParameterType[], + options?: SqliteQueryOptions | undefined, + ): AsyncGenerator { + return this.connection.queryManyArray(sql, params, options); + } + + sql = Row>( + strings: TemplateStringsArray, + ...parameters: BindValue[] + ): Promise { + const sql = strings.join("?"); + return this.query(sql, parameters); + } + + sqlArray = ArrayRow>( + strings: TemplateStringsArray, + ...parameters: BindValue[] + ): Promise { + const sql = strings.join("?"); + return this.queryArray(sql, parameters); + } +} + +export class SqlitePreparable extends SqliteQueriable implements + SqlPreparable< + SqliteConnectionOptions, + SqliteParameterType, + SqliteQueryOptions, + SqliteConnection, + SqlitePreparedStatement + > { +} + +export class SqliteTransaction extends SqliteQueriable + implements + SqlTransaction< + SqliteConnectionOptions, + SqliteParameterType, + SqliteQueryOptions, + SqliteConnection, + SqlitePreparedStatement, + SqliteTransactionOptions + > { + #inTransaction: boolean = true; + get inTransaction(): boolean { + return this.connected && this.#inTransaction; + } + + get connected(): boolean { + if (!this.#inTransaction) { + throw new SqliteTransactionError( + "Transaction is not active, create a new one using beginTransaction", + ); + } + + return super.connected; + } + + async commitTransaction( + _options?: SqliteTransactionOptions["commitTransactionOptions"], + ): Promise { + try { + await this.execute("COMMIT"); + } catch (e) { + this.#inTransaction = false; + throw e; + } + } + async rollbackTransaction( + options?: SqliteTransactionOptions["rollbackTransactionOptions"], + ): Promise { + try { + if (options?.savepoint) { + await this.execute("ROLLBACK TO ?", [options.savepoint]); + } else { + await this.execute("ROLLBACK"); + } + } catch (e) { + this.#inTransaction = false; + throw e; + } + } + async createSavepoint(name: string = `\t_bs3.\t`): Promise { + await this.execute(`SAVEPOINT ${name}`); + } + async releaseSavepoint(name: string = `\t_bs3.\t`): Promise { + await this.execute(`RELEASE ${name}`); + } +} + +/** + * Represents a queriable class that can be used to run transactions. + */ +export class SqliteTransactionable extends SqlitePreparable + implements + SqlTransactionable< + SqliteConnectionOptions, + SqliteParameterType, + SqliteQueryOptions, + SqliteConnection, + SqlitePreparedStatement, + SqliteTransactionOptions, + SqliteTransaction + > { + async beginTransaction( + options?: SqliteTransactionOptions["beginTransactionOptions"], + ): Promise { + let sql = "BEGIN"; + if (options?.behavior) { + sql += ` ${options.behavior}`; + } + await this.execute(sql); + + return new SqliteTransaction(this.connection, this.options); + } + + async transaction( + fn: (t: SqliteTransaction) => Promise, + options?: SqliteTransactionOptions, + ): Promise { + const transaction = await this.beginTransaction( + options?.beginTransactionOptions, + ); + + try { + const result = await fn(transaction); + await transaction.commitTransaction(options?.commitTransactionOptions); + return result; + } catch (error) { + await transaction.rollbackTransaction( + options?.rollbackTransactionOptions, + ); + throw error; + } + } +} + +/** + * Sqlite client + */ +export class SqliteClient extends SqliteTransactionable implements + SqlClient< + SqliteEventTarget, + SqliteConnectionOptions, + SqliteParameterType, + SqliteQueryOptions, + SqliteConnection, + SqlitePreparedStatement, + SqliteTransactionOptions, + SqliteTransaction + > { + readonly eventTarget: SqliteEventTarget; + + constructor( + connectionUrl: string | URL, + options: SqliteClientOptions = {}, + ) { + const conn = new SqliteConnection(connectionUrl, options); + super(conn, options); + this.eventTarget = new SqliteEventTarget(); + } + + async connect(): Promise { + await this.connection.connect(); + this.eventTarget.dispatchEvent( + new SqliteConnectEvent({ connection: this.connection }), + ); + } + async close(): Promise { + this.eventTarget.dispatchEvent( + new SqliteCloseEvent({ connection: this.connection }), + ); + await this.connection.close(); + } + + async [Symbol.asyncDispose](): Promise { + await this.close(); + } +} diff --git a/src/database.ts b/src/database.ts index f47f053..fa7127e 100644 --- a/src/database.ts +++ b/src/database.ts @@ -1,5 +1,5 @@ -import ffi from "./ffi.ts"; -import { fromFileUrl } from "../deps.ts"; +import ffi, { unwrap } from "./ffi.ts"; +import { fromFileUrl } from "@std/path"; import { SQLITE3_OPEN_CREATE, SQLITE3_OPEN_MEMORY, @@ -11,7 +11,7 @@ import { SQLITE_NULL, SQLITE_TEXT, } from "./constants.ts"; -import { readCstr, toCString, unwrap } from "./util.ts"; +import { toCString } from "./util.ts"; import { type RestBindParameters, Statement, @@ -83,8 +83,6 @@ const { sqlite3_get_autocommit, sqlite3_exec, sqlite3_free, - sqlite3_libversion, - sqlite3_sourceid, sqlite3_complete, sqlite3_finalize, sqlite3_result_blob, @@ -110,11 +108,6 @@ const { sqlite3_errcode, } = ffi; -/** SQLite version string */ -export const SQLITE_VERSION: string = readCstr(sqlite3_libversion()!); -/** SQLite source ID string */ -export const SQLITE_SOURCEID: string = readCstr(sqlite3_sourceid()!); - /** * Whether the given SQL statement is complete. * @@ -277,7 +270,7 @@ export class Database { * @returns Statement object */ prepare(sql: string): Statement { - return new Statement(this, sql); + return new Statement(this.#handle, sql); } /** @@ -326,7 +319,7 @@ export class Database { ); const errPtr = Deno.UnsafePointer.create(pErr[0]); if (errPtr !== null) { - const err = readCstr(errPtr); + const err = Deno.UnsafePointerView.getCString(errPtr); sqlite3_free(errPtr); throw new Error(err); } @@ -334,7 +327,7 @@ export class Database { } const stmt = this.prepare(sql); - stmt.run(...params); + stmt.run(params); return sqlite3_changes(this.#handle); } @@ -350,7 +343,7 @@ export class Database { ): T[] { const sql = strings.join("?"); const stmt = this.prepare(sql); - return stmt.all(...parameters); + return stmt.all(parameters); } /** @@ -758,7 +751,7 @@ export class Database { pzErrMsg[0], ); if (pzErrPtr !== null) { - const pzErr = readCstr(pzErrPtr); + const pzErr = Deno.UnsafePointerView.getCString(pzErrPtr); sqlite3_free(pzErrPtr); throw new Error(pzErr); } diff --git a/src/errors.ts b/src/errors.ts new file mode 100644 index 0000000..dc7d8c2 --- /dev/null +++ b/src/errors.ts @@ -0,0 +1,20 @@ +import { isSqlError, SqlError } from "@stdext/sql"; + +export class SqliteError extends SqlError { + constructor(msg: string) { + super(msg); + } +} + +export class SqliteTransactionError extends SqliteError { + constructor(msg: string) { + super(msg); + } +} + +/** + * Check if an error is a SqliteError + */ +export function isSqliteError(err: unknown): err is SqliteError { + return isSqlError(err) && err instanceof SqliteError; +} diff --git a/src/events.test.ts b/src/events.test.ts new file mode 100644 index 0000000..deefd4c --- /dev/null +++ b/src/events.test.ts @@ -0,0 +1,6 @@ +import { SqliteEventTarget } from "./events.ts"; +import { testSqlEventTarget } from "@stdext/sql/testing"; + +Deno.test("event constructs", () => { + testSqlEventTarget(new SqliteEventTarget()); +}); diff --git a/src/events.ts b/src/events.ts new file mode 100644 index 0000000..5da90dd --- /dev/null +++ b/src/events.ts @@ -0,0 +1,33 @@ +import { + type SqlClientEventType, + SqlCloseEvent, + SqlConnectEvent, + type SqlConnectionEventInit, + SqlEventTarget, +} from "@stdext/sql"; +import type { + SqliteConnection, + SqliteConnectionOptions, +} from "./connection.ts"; + +export class SqliteEventTarget extends SqlEventTarget< + SqliteConnectionOptions, + SqliteConnection, + SqlClientEventType, + SqliteConnectionEventInit, + SqliteEvents +> { +} + +export type SqliteConnectionEventInit = SqlConnectionEventInit< + SqliteConnection +>; + +export class SqliteConnectEvent + extends SqlConnectEvent {} +export class SqliteCloseEvent + extends SqlCloseEvent {} + +export type SqliteEvents = + | SqliteConnectEvent + | SqliteCloseEvent; diff --git a/src/ffi.ts b/src/ffi.ts index f28757e..0b145e3 100644 --- a/src/ffi.ts +++ b/src/ffi.ts @@ -1,5 +1,7 @@ import meta from "../deno.json" with { type: "json" }; -import { dlopen } from "../deps.ts"; +import { dlopen } from "@denosaurs/plug"; +import { SQLITE3_DONE, SQLITE3_MISUSE, SQLITE3_OK } from "./constants.ts"; +import { SqliteError } from "./util.ts"; const symbols = { sqlite3_open_v2: { @@ -639,3 +641,28 @@ if (init !== 0) { } export default lib; + +export function unwrap(code: number, db?: Deno.PointerValue): void { + if (code === SQLITE3_OK || code === SQLITE3_DONE) return; + if (code === SQLITE3_MISUSE) { + throw new SqliteError(code, "SQLite3 API misuse"); + } else if (db !== undefined) { + const errmsg = lib.sqlite3_errmsg(db); + if (errmsg === null) throw new SqliteError(code); + throw new Error(Deno.UnsafePointerView.getCString(errmsg)); + } else { + throw new SqliteError( + code, + Deno.UnsafePointerView.getCString(lib.sqlite3_errstr(code)!), + ); + } +} + +/** SQLite version string */ +export const SQLITE_VERSION: string = Deno.UnsafePointerView.getCString( + lib.sqlite3_libversion()!, +); +/** SQLite source ID string */ +export const SQLITE_SOURCEID: string = Deno.UnsafePointerView.getCString( + lib.sqlite3_sourceid()!, +); diff --git a/src/statement.ts b/src/statement.ts index 1d33c7c..90a2511 100644 --- a/src/statement.ts +++ b/src/statement.ts @@ -1,6 +1,5 @@ -import type { Database } from "./database.ts"; -import { readCstr, toCString, unwrap } from "./util.ts"; -import ffi from "./ffi.ts"; +import { toCString } from "./util.ts"; +import ffi, { unwrap } from "./ffi.ts"; import { SQLITE3_DONE, SQLITE3_ROW, @@ -86,7 +85,7 @@ function getColumn(handle: Deno.PointerValue, i: number, int64: boolean): any { case SQLITE_TEXT: { const ptr = sqlite3_column_text(handle, i); if (ptr === null) return null; - const text = readCstr(ptr, 0); + const text = Deno.UnsafePointerView.getCString(ptr, 0); const value = sqlite3_column_value(handle, i); const subtype = sqlite3_value_subtype(value); if (subtype === JSON_SUBTYPE) { @@ -130,6 +129,13 @@ function getColumn(handle: Deno.PointerValue, i: number, int64: boolean): any { } } +export interface StatementOptions { + /** Uses unsafe concurrency */ + unsafeConcurrency?: boolean; + /** Whether to use int64 for integer values. */ + int64?: boolean; +} + /** * Represents a prepared statement. * @@ -140,7 +146,7 @@ export class Statement { #finalizerToken: { handle: Deno.PointerValue }; #bound = false; #hasNoArgs = false; - #unsafeConcurrency; + #options: StatementOptions; /** * Whether the query might call into JavaScript or not. @@ -159,12 +165,14 @@ export class Statement { /** SQL string including bindings */ get expandedSql(): string { - return readCstr(sqlite3_expanded_sql(this.#handle)!); + return Deno.UnsafePointerView.getCString( + sqlite3_expanded_sql(this.#handle)!, + ); } /** The SQL string that we passed when creating statement */ get sql(): string { - return readCstr(sqlite3_sql(this.#handle)!); + return Deno.UnsafePointerView.getCString(sqlite3_sql(this.#handle)!); } /** Whether this statement doesn't make any direct changes to the DB */ @@ -173,25 +181,84 @@ export class Statement { } /** Simply run the query without retrieving any output there may be. */ - run(...args: RestBindParameters): number { - return this.#runWithArgs(...args); + run(args?: BindParameters): number { + if (this.#hasNoArgs) { + return this.#runNoArgs(); + } else { + if (args === undefined) { + throw new Error("Arguments must be provided for this statement"); + } + return this.#runWithArgs(args); + } } /** * Run the query and return the resulting rows where rows are array of columns. */ - values(...args: RestBindParameters): T[] { - return this.#valuesWithArgs(...args); + values( + args?: BindParameters, + options?: StatementOptions, + ): T[] { + if (this.#hasNoArgs) { + return this.#valuesNoArgs(options); + } else { + if (args === undefined) { + throw new Error("Arguments must be provided for this statement"); + } + return this.#valuesWithArgs(args, options); + } } /** * Run the query and return the resulting rows where rows are objects * mapping column name to their corresponding values. */ - all>( - ...args: RestBindParameters + all = Record>( + args?: BindParameters, + options?: StatementOptions, ): T[] { - return this.#allWithArgs(...args); + if (this.#hasNoArgs) { + return this.#allNoArgs(options); + } else { + if (args === undefined) { + throw new Error("Arguments must be provided for this statement"); + } + return this.#allWithArgs(args, options); + } + } + + /** + * Fetch only first row as an array, if any. + */ + value( + args?: BindParameters, + options?: StatementOptions, + ): T | undefined { + if (this.#hasNoArgs) { + return this.#valueNoArgs(options); + } else { + if (args === undefined) { + throw new Error("Arguments must be provided for this statement"); + } + return this.#valueWithArgs(args, options); + } + } + + /** + * Fetch only first row as an object, if any. + */ + get = Record>( + args?: BindParameters, + options?: StatementOptions, + ): T | undefined { + if (this.#hasNoArgs) { + return this.#getNoArgs(options); + } else { + if (args === undefined) { + throw new Error("Arguments must be provided for this statement"); + } + return this.#getWithArgs(args, options); + } } #bindParameterCount: number; @@ -201,35 +268,34 @@ export class Statement { return this.#bindParameterCount; } - constructor(public db: Database, sql: string) { - const pHandle = new BigUint64Array(1); + constructor( + public dbPointer: Deno.PointerValue, + sql: string, + options: StatementOptions = {}, + ) { + this.#options = options; + + const pHandle = new Uint32Array(2); unwrap( sqlite3_prepare_v2( - db.unsafeHandle, + this.dbPointer, toCString(sql), sql.length, pHandle, null, ), - db.unsafeHandle, + this.dbPointer, + ); + this.#handle = Deno.UnsafePointer.create( + BigInt(pHandle[0] + 2 ** 32 * pHandle[1]), ); - this.#handle = Deno.UnsafePointer.create(pHandle[0]); - STATEMENTS_TO_DB.set(this.#handle, db.unsafeHandle); - this.#unsafeConcurrency = db.unsafeConcurrency; + STATEMENTS_TO_DB.set(this.#handle, this.dbPointer); this.#finalizerToken = { handle: this.#handle }; statementFinalizer.register(this, this.#handle, this.#finalizerToken); - if ( - (this.#bindParameterCount = sqlite3_bind_parameter_count( - this.#handle, - )) === 0 - ) { + this.#bindParameterCount = sqlite3_bind_parameter_count(this.#handle); + if (this.#bindParameterCount === 0) { this.#hasNoArgs = true; - this.all = this.#allNoArgs; - this.values = this.#valuesNoArgs; - this.run = this.#runNoArgs; - this.value = this.#valueNoArgs; - this.get = this.#getNoArgs; } } @@ -241,7 +307,9 @@ export class Statement { /** Get bind parameter name by index */ bindParameterName(i: number): string { - return readCstr(sqlite3_bind_parameter_name(this.#handle, i)!); + return Deno.UnsafePointerView.getCString( + sqlite3_bind_parameter_name(this.#handle, i)!, + ); } /** Get bind parameter index by name */ @@ -366,20 +434,14 @@ export class Statement { * This method is merely just for optimization to avoid binding parameters * each time in prepared statement. */ - bind(...params: RestBindParameters): this { + bind(params: BindParameters): this { this.#bindAll(params); this.#bound = true; return this; } - #bindAll(params: RestBindParameters | BindParameters): void { + #bindAll(params: BindParameters): void { if (this.#bound) throw new Error("Statement already bound to values"); - if ( - typeof params[0] === "object" && params[0] !== null && - !(params[0] instanceof Uint8Array) && !(params[0] instanceof Date) - ) { - params = params[0]; - } if (Array.isArray(params)) { for (let i = 0; i < params.length; i++) { this.#bind(i, (params as BindValue[])[i]); @@ -398,30 +460,31 @@ export class Statement { #runNoArgs(): number { const handle = this.#handle; this.#begin(); - const status = sqlite3_step(handle); + const status = sqlite3_step(this.#handle); if (status !== SQLITE3_ROW && status !== SQLITE3_DONE) { - unwrap(status, this.db.unsafeHandle); + unwrap(status, this.dbPointer); } sqlite3_reset(handle); - return sqlite3_changes(this.db.unsafeHandle); + return sqlite3_changes(this.dbPointer); } - #runWithArgs(...params: RestBindParameters): number { + #runWithArgs(params: BindParameters): number { const handle = this.#handle; this.#begin(); this.#bindAll(params); - const status = sqlite3_step(handle); + const status = sqlite3_step(this.#handle); if (!this.#hasNoArgs && !this.#bound && params.length) { this.#bindRefs.clear(); } if (status !== SQLITE3_ROW && status !== SQLITE3_DONE) { - unwrap(status, this.db.unsafeHandle); + unwrap(status, this.dbPointer); } sqlite3_reset(handle); - return sqlite3_changes(this.db.unsafeHandle); + return sqlite3_changes(this.dbPointer); } - #valuesNoArgs>(): T[] { + #valuesNoArgs>(options?: StatementOptions): T[] { + const mergedOptions = this.#getOptions(options); const handle = this.#handle; this.#begin(); const columnCount = sqlite3_column_count(handle); @@ -432,28 +495,30 @@ export class Statement { return function(h) { return [${ Array.from({ length: columnCount }).map((_, i) => - `getColumn(h, ${i}, ${this.db.int64})` + `getColumn(h, ${i}, ${mergedOptions.int64})` ) .join(", ") }]; }; `, )(getColumn); - let status = sqlite3_step(handle); + let status = sqlite3_step(this.#handle); while (status === SQLITE3_ROW) { result.push(getRowArray(handle)); status = sqlite3_step(handle); } if (status !== SQLITE3_DONE) { - unwrap(status, this.db.unsafeHandle); + unwrap(status, this.dbPointer); } sqlite3_reset(handle); return result as T[]; } #valuesWithArgs>( - ...params: RestBindParameters + params: BindParameters, + options?: StatementOptions, ): T[] { + const mergedOptions = this.#getOptions(options); const handle = this.#handle; this.#begin(); this.#bindAll(params); @@ -465,7 +530,7 @@ export class Statement { return function(h) { return [${ Array.from({ length: columnCount }).map((_, i) => - `getColumn(h, ${i}, ${this.db.int64})` + `getColumn(h, ${i}, ${mergedOptions.int64})` ) .join(", ") }]; @@ -481,7 +546,7 @@ export class Statement { this.#bindRefs.clear(); } if (status !== SQLITE3_DONE) { - unwrap(status, this.db.unsafeHandle); + unwrap(status, this.dbPointer); } sqlite3_reset(handle); return result as T[]; @@ -489,9 +554,10 @@ export class Statement { #rowObjectFn: ((h: Deno.PointerValue) => any) | undefined; - getRowObject(): (h: Deno.PointerValue) => any { - if (!this.#rowObjectFn || !this.#unsafeConcurrency) { - const columnNames = this.columnNames(); + getRowObject(options?: StatementOptions): (h: Deno.PointerValue) => any { + const mergedOptions = this.#getOptions(options); + if (!this.#rowObjectFn || !mergedOptions.unsafeConcurrency) { + const columnNames = this.columnNames(options); const getRowObject = new Function( "getColumn", ` @@ -499,7 +565,7 @@ export class Statement { return { ${ columnNames.map((name, i) => - `"${name}": getColumn(h, ${i}, ${this.db.int64})` + `"${name}": getColumn(h, ${i}, ${mergedOptions.int64})` ).join(",\n") } }; @@ -511,10 +577,12 @@ export class Statement { return this.#rowObjectFn!; } - #allNoArgs(): T[] { + #allNoArgs>( + options?: StatementOptions, + ): T[] { const handle = this.#handle; this.#begin(); - const getRowObject = this.getRowObject(); + const getRowObject = this.getRowObject(options); const result: T[] = []; let status = sqlite3_step(handle); while (status === SQLITE3_ROW) { @@ -522,19 +590,20 @@ export class Statement { status = sqlite3_step(handle); } if (status !== SQLITE3_DONE) { - unwrap(status, this.db.unsafeHandle); + unwrap(status, this.dbPointer); } sqlite3_reset(handle); return result as T[]; } - #allWithArgs( - ...params: RestBindParameters + #allWithArgs>( + params: BindParameters, + options?: StatementOptions, ): T[] { const handle = this.#handle; this.#begin(); this.#bindAll(params); - const getRowObject = this.getRowObject(); + const getRowObject = this.getRowObject(options); const result: T[] = []; let status = sqlite3_step(handle); while (status === SQLITE3_ROW) { @@ -545,27 +614,22 @@ export class Statement { this.#bindRefs.clear(); } if (status !== SQLITE3_DONE) { - unwrap(status, this.db.unsafeHandle); + unwrap(status, this.dbPointer); } sqlite3_reset(handle); return result as T[]; } /** Fetch only first row as an array, if any. */ - value>( - ...params: RestBindParameters + #valueWithArgs>( + params: BindParameters, + options?: StatementOptions, ): T | undefined { + const mergedOptions = this.#getOptions(options); const handle = this.#handle; - const int64 = this.db.int64; const arr = new Array(sqlite3_column_count(handle)); - sqlite3_reset(handle); - if (!this.#hasNoArgs && !this.#bound) { - sqlite3_clear_bindings(handle); - this.#bindRefs.clear(); - if (params.length) { - this.#bindAll(params); - } - } + this.#begin(); + this.#bindAll(params); const status = sqlite3_step(handle); @@ -575,46 +639,51 @@ export class Statement { if (status === SQLITE3_ROW) { for (let i = 0; i < arr.length; i++) { - arr[i] = getColumn(handle, i, int64); + arr[i] = getColumn(handle, i, mergedOptions.int64); } sqlite3_reset(this.#handle); return arr as T; } else if (status === SQLITE3_DONE) { return; } else { - unwrap(status, this.db.unsafeHandle); + unwrap(status, this.dbPointer); } } - #valueNoArgs>(): T | undefined { + #valueNoArgs>( + options?: StatementOptions, + ): T | undefined { + const mergedOptions = this.#getOptions(options); const handle = this.#handle; - const int64 = this.db.int64; const cc = sqlite3_column_count(handle); const arr = new Array(cc); sqlite3_reset(handle); const status = sqlite3_step(handle); if (status === SQLITE3_ROW) { for (let i = 0; i < cc; i++) { - arr[i] = getColumn(handle, i, int64); + arr[i] = getColumn(handle, i, mergedOptions.int64); } sqlite3_reset(this.#handle); return arr as T; } else if (status === SQLITE3_DONE) { return; } else { - unwrap(status, this.db.unsafeHandle); + unwrap(status, this.dbPointer); } } #columnNames: string[] | undefined; #rowObject: Record = {}; - columnNames(): string[] { - if (!this.#columnNames || !this.#unsafeConcurrency) { + columnNames(options?: StatementOptions): string[] { + const mergedOptions = this.#getOptions(options); + if (!this.#columnNames || !mergedOptions.unsafeConcurrency) { const columnCount = sqlite3_column_count(this.#handle); const columnNames = new Array(columnCount); for (let i = 0; i < columnCount; i++) { - columnNames[i] = readCstr(sqlite3_column_name(this.#handle, i)!); + columnNames[i] = Deno.UnsafePointerView.getCString( + sqlite3_column_name(this.#handle, i)!, + ); } this.#columnNames = columnNames; this.#rowObject = {}; @@ -626,23 +695,18 @@ export class Statement { } /** Fetch only first row as an object, if any. */ - get( - ...params: RestBindParameters + #getWithArgs>( + params: BindParameters, + options?: StatementOptions, ): T | undefined { + const mergedOptions = this.#getOptions(options); const handle = this.#handle; - const int64 = this.db.int64; - const columnNames = this.columnNames(); + const columnNames = this.columnNames(options); const row: Record = {}; - sqlite3_reset(handle); - if (!this.#hasNoArgs && !this.#bound) { - sqlite3_clear_bindings(handle); - this.#bindRefs.clear(); - if (params.length) { - this.#bindAll(params); - } - } + this.#begin(); + this.#bindAll(params); const status = sqlite3_step(handle); @@ -652,34 +716,36 @@ export class Statement { if (status === SQLITE3_ROW) { for (let i = 0; i < columnNames.length; i++) { - row[columnNames[i]] = getColumn(handle, i, int64); + row[columnNames[i]] = getColumn(handle, i, mergedOptions.int64); } sqlite3_reset(this.#handle); return row as T; } else if (status === SQLITE3_DONE) { return; } else { - unwrap(status, this.db.unsafeHandle); + unwrap(status, this.dbPointer); } } - #getNoArgs(): T | undefined { + #getNoArgs>( + options?: StatementOptions, + ): T | undefined { + const mergedOptions = this.#getOptions(options); const handle = this.#handle; - const int64 = this.db.int64; - const columnNames = this.columnNames(); + const columnNames = this.columnNames(options); const row: Record = this.#rowObject; sqlite3_reset(handle); const status = sqlite3_step(handle); if (status === SQLITE3_ROW) { for (let i = 0; i < columnNames?.length; i++) { - row[columnNames[i]] = getColumn(handle, i, int64); + row[columnNames[i]] = getColumn(handle, i, mergedOptions.int64); } sqlite3_reset(handle); return row as T; } else if (status === SQLITE3_DONE) { return; } else { - unwrap(status, this.db.unsafeHandle); + unwrap(status, this.dbPointer); } } @@ -694,27 +760,82 @@ export class Statement { /** Coerces the statement to a string, which in this case is expanded SQL. */ toString(): string { - return readCstr(sqlite3_expanded_sql(this.#handle)!); + return Deno.UnsafePointerView.getCString( + sqlite3_expanded_sql(this.#handle)!, + ); } - /** Iterate over resultant rows from query. */ - *iter(...params: RestBindParameters): IterableIterator { + /** + * Iterate over resultant rows from query as objects. + */ + *getMany>( + params?: BindParameters, + options?: StatementOptions, + ): IterableIterator { this.#begin(); - this.#bindAll(params); - const getRowObject = this.getRowObject(); + if (!this.#bound && !this.#hasNoArgs && params?.length) { + this.#bindAll(params); + } + const getRowObject = this.getRowObject(options); let status = sqlite3_step(this.#handle); while (status === SQLITE3_ROW) { yield getRowObject(this.#handle); status = sqlite3_step(this.#handle); } if (status !== SQLITE3_DONE) { - unwrap(status, this.db.unsafeHandle); + unwrap(status, this.dbPointer); } sqlite3_reset(this.#handle); } + /** + * Iterate over resultant rows from query as arrays. + */ + *valueMany>( + params?: BindParameters, + options?: StatementOptions, + ): IterableIterator { + const mergedOptions = this.#getOptions(options); + const handle = this.#handle; + this.#begin(); + if (!this.#bound && !this.#hasNoArgs && params?.length) { + this.#bindAll(params); + } + const columnCount = sqlite3_column_count(handle); + const getRowArray = new Function( + "getColumn", + ` + return function(h) { + return [${ + Array.from({ length: columnCount }).map((_, i) => + `getColumn(h, ${i}, ${mergedOptions.int64})` + ) + .join(", ") + }]; + }; + `, + )(getColumn); + let status = sqlite3_step(handle); + while (status === SQLITE3_ROW) { + yield getRowArray(handle); + status = sqlite3_step(handle); + } + if (status !== SQLITE3_DONE) { + unwrap(status, this.dbPointer); + } + sqlite3_reset(handle); + } + + #getOptions(options?: StatementOptions): Required { + return { + int64: options?.int64 ?? this.#options.int64 ?? false, + unsafeConcurrency: options?.unsafeConcurrency ?? + this.#options.unsafeConcurrency ?? false, + }; + } + [Symbol.iterator](): IterableIterator { - return this.iter(); + return this.getMany(); } [Symbol.dispose](): void { diff --git a/src/util.ts b/src/util.ts index ba12694..7de7146 100644 --- a/src/util.ts +++ b/src/util.ts @@ -1,10 +1,5 @@ -import { SQLITE3_DONE, SQLITE3_MISUSE, SQLITE3_OK } from "./constants.ts"; -import ffi from "./ffi.ts"; - -const { - sqlite3_errmsg, - sqlite3_errstr, -} = ffi; +import { SqlError } from "@stdext/sql"; +import type { SqliteQueryOptions } from "./core.ts"; export const encoder = new TextEncoder(); @@ -12,11 +7,7 @@ export function toCString(str: string): Uint8Array { return encoder.encode(str + "\0"); } -export function isObject(value: unknown): boolean { - return typeof value === "object" && value !== null; -} - -export class SqliteError extends Error { +export class SqliteError extends SqlError { name = "SqliteError"; constructor( @@ -27,22 +18,23 @@ export class SqliteError extends Error { } } -export function unwrap(code: number, db?: Deno.PointerValue): void { - if (code === SQLITE3_OK || code === SQLITE3_DONE) return; - if (code === SQLITE3_MISUSE) { - throw new SqliteError(code, "SQLite3 API misuse"); - } else if (db !== undefined) { - const errmsg = sqlite3_errmsg(db); - if (errmsg === null) throw new SqliteError(code); - throw new Error(Deno.UnsafePointerView.getCString(errmsg)); - } else { - throw new SqliteError( - code, - Deno.UnsafePointerView.getCString(sqlite3_errstr(code)!), - ); - } +export function transformToAsyncGenerator< + T extends unknown, + I extends IterableIterator, +>(iterableIterator: I): AsyncGenerator { + return iterableIterator as unknown as AsyncGenerator; } -export const buf = Deno.UnsafePointerView.getArrayBuffer; +export function mergeQueryOptions( + ...options: (SqliteQueryOptions | undefined)[] +): SqliteQueryOptions { + const mergedOptions: SqliteQueryOptions = {}; -export const readCstr = Deno.UnsafePointerView.getCString; + for (const option of options) { + if (option) { + Object.assign(mergedOptions, option); + } + } + + return mergedOptions; +} diff --git a/std_sql.ts b/std_sql.ts new file mode 100644 index 0000000..66e87ac --- /dev/null +++ b/std_sql.ts @@ -0,0 +1,13 @@ +export * from "./src/core.ts"; +export * from "./src/connection.ts"; +export * from "./src/errors.ts"; +export * from "./src/events.ts"; + +export { type BlobOpenOptions, SQLBlob } from "./src/blob.ts"; +export { + type BindParameters, + type BindValue, + Statement, +} from "./src/statement.ts"; +export { SqliteError } from "./src/util.ts"; +export { SQLITE_SOURCEID, SQLITE_VERSION } from "./src/ffi.ts"; diff --git a/test/deps.ts b/test/deps.ts deleted file mode 100644 index 6392beb..0000000 --- a/test/deps.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "https://deno.land/std@0.179.0/testing/asserts.ts"; diff --git a/test/test.ts b/test/test.ts index fe46f0f..e883456 100644 --- a/test/test.ts +++ b/test/test.ts @@ -5,7 +5,7 @@ import { SQLITE_VERSION, SqliteError, } from "../mod.ts"; -import { assert, assertEquals, assertThrows } from "./deps.ts"; +import { assert, assertEquals, assertThrows } from "@std/assert"; console.log("sqlite version:", SQLITE_VERSION); @@ -167,10 +167,10 @@ Deno.test("sqlite", async (t) => { double: number; blob: Uint8Array; nullable: null; - }>( + }>([ 1, "hello world", - ); + ]); assertEquals(rows.length, 9); for (const row of rows) { @@ -197,7 +197,7 @@ Deno.test("sqlite", async (t) => { await t.step("query with string param", () => { const row = db.prepare( "select * from test where text = ?", - ).values<[number, string, number, Uint8Array, null]>("hello 0")[0]; + ).values<[number, string, number, Uint8Array, null]>(["hello 0"])[0]; assertEquals(row[0], 0); assertEquals(row[1], "hello 0"); @@ -220,13 +220,13 @@ Deno.test("sqlite", async (t) => { await t.step("query parameters", () => { const row = db.prepare( "select ?, ?, ?, ?, ?", - ).values<[number, string, string, string, string]>( + ).values<[number, string, string, string, string]>([ 1, "alex", new Date("2023-01-01"), [1, 2, 3], { name: "alex" }, - )[0]; + ])[0]; assertEquals(row[0], 1); assertEquals(row[1], "alex"); @@ -260,7 +260,7 @@ Deno.test("sqlite", async (t) => { ); const [int] = db.prepare( "select integer from test where text = ?", - ).values<[number]>("bigint")[0]; + ).values<[number]>(["bigint"], { int64: true })[0]; assertEquals(int, value); }); @@ -277,7 +277,7 @@ Deno.test("sqlite", async (t) => { ); const [int] = db.prepare( "select integer from test where text = ?", - ).values<[number]>("bigint2")[0]; + ).values<[number]>(["bigint2"], { int64: true })[0]; assertEquals(int, value); }); @@ -294,7 +294,7 @@ Deno.test("sqlite", async (t) => { ); const [int] = db.prepare( "select integer from test where text = ?", - ).values<[bigint]>("bigint3")[0]; + ).values<[bigint]>(["bigint3"], { int64: true })[0]; assertEquals(int, value); }); @@ -310,7 +310,7 @@ Deno.test("sqlite", async (t) => { ); const [int, double] = db.prepare( "select integer, double from test where text = ?", - ).values<[number, number]>("nan")[0]; + ).values<[number, number]>(["nan"])[0]; assertEquals(int, null); assertEquals(double, null); }); @@ -462,37 +462,37 @@ Deno.test("sqlite", async (t) => { const [result] = db .prepare("select deno_add(?, ?)") .enableCallback() - .value<[number]>(1, 2)!; + .value<[number]>([1, 2])!; assertEquals(result, 3); const [result2] = db .prepare("select deno_uppercase(?)") .enableCallback() - .value<[string]>("hello")!; + .value<[string]>(["hello"])!; assertEquals(result2, "HELLO"); const [result3] = db .prepare("select deno_buffer_add_1(?)") .enableCallback() - .value<[Uint8Array]>(new Uint8Array([1, 2, 3]))!; + .value<[Uint8Array]>([new Uint8Array([1, 2, 3])])!; assertEquals(result3, new Uint8Array([2, 3, 4])); - const [result4] = db.prepare("select deno_add(?, ?)").value<[number]>( + const [result4] = db.prepare("select deno_add(?, ?)").value<[number]>([ 1.5, 1.5, - )!; + ])!; assertEquals(result4, 3); const [result5] = db .prepare("select regexp(?, ?)") .enableCallback() - .value<[number]>("hello", "h.*")!; + .value<[number]>(["hello", "h.*"])!; assertEquals(result5, 1); const [result6] = db .prepare("select regexp(?, ?)") .enableCallback() - .value<[number]>("hello", "x.*")!; + .value<[number]>(["hello", "x.*"])!; assertEquals(result6, 0); db.exec("create table aggr_test (value integer)");