import * as sqlite3 from '../sqlite3.pure'; import * as bindings from './bindings'; import Database from './database'; import { ErrorCallback, last, queue, tick } from './util'; // TODO: accept more types type Parameter = string | boolean | number | Date | RegExp | null; type GetCallback = (error: Error | null, row?: any) => void; type AllCallback = (error: Error | null, rows?: any[]) => void; type CompleteCallback = (error: Error | null, rows?: number) => void; type RunCallback = (this: Statement, error: Error | null) => void; type IgnoreError = Error & { ignore?: true }; const NOOP = () => { /* NOOP */ }; const registry = new FinalizationRegistry((heldValue: { handle: number | undefined }) => { const handle = heldValue.handle; if (handle == null) { return; } bindings.sqlite3_finalize_silent(handle); }); export default class Statement { sql: string; lastID?: number; changes?: number; private _state: { status: 'new' } | { status: 'init'; handle: number; done: boolean } | { status: 'finalized' } = { status: 'new', }; private _db: Database; private _bound = false; private _queue = queue(); private _gcHandle: { handle: number | undefined } = { handle: undefined }; private constructor(db: Database, sql: string, errBack?: ErrorCallback) { this.sql = sql; this._db = db; tick(() => { // TODO: handle `db._handle() == null` let thrown; let handle = undefined; try { handle = bindings.sqlite3_prepare_v2(db._getHandle()!, sql); this._state = { status: 'init', handle, done: false }; } catch (error: any) { thrown = { error }; this._state = { status: 'new' }; } this._gcHandle = { handle }; registry.register(this, this._gcHandle); if (errBack && thrown?.error) { errBack(thrown.error); } }); } bind(params: Parameter[], callback?: ErrorCallback): void; bind(params: { [key: string]: Parameter }, callback?: ErrorCallback): void; bind(...params: unknown[]): void; bind(...params: unknown[]) { const callback: ErrorCallback = typeof last(params) === 'function' ? (params.pop() as ErrorCallback) : NOOP; this._tick(callback, () => { this._bind(params); callback(null); }); return this; } private _bind(params: unknown[]) { return this._doBind(parseParams(params)); } private _doBind({ params, isMap }: { params: [string, Parameter][]; isMap: boolean }) { const handle = this._getHandle(); const dbHandle = this._db._getHandle(); if (dbHandle == null) { /* * This should not happen, since the statement already exists and * the db cannot be closed */ throw new Error('Unexpected invalid database'); } if (this._bound && params.length > 0) { bindings.sqlite3_clear_bindings(handle); this._bound = false; } for (const [name, value] of params) { const asNumber = parseInt(name, 10); let index: number; if (Number.isNaN(asNumber)) { index = bindings.sqlite3_bind_parameter_index(handle, name); if (index === 0) { throw new TypeError(`Invalid parameter ${name}`); } } else { index = asNumber + (isMap ? 0 : 1); } let doBindParameter; if (typeof value === 'number' && Number.isSafeInteger(value)) { /** * TODO: int64 * ints are tricky because, internally, all SQLite integers are 64 bits, and OTOH * WASM can't really do i64 without involving BigInt's, which is a PITA. * * For now, we'll just settle for passing and receiving doubles everywhere. */ doBindParameter = () => bindings.sqlite3_bind_double(handle, dbHandle, index, value); } else if (typeof value === 'number') { doBindParameter = () => bindings.sqlite3_bind_double(handle, dbHandle, index, value); } else if (typeof value === 'boolean') { doBindParameter = () => bindings.sqlite3_bind_int(handle, dbHandle, index, value ? 1 : 0); } else if (value === null) { doBindParameter = () => bindings.sqlite3_bind_null(handle, dbHandle, index); } else if (typeof value === 'string') { doBindParameter = () => bindings.sqlite3_bind_text(handle, dbHandle, index, value); } else if (value instanceof Date) { doBindParameter = () => bindings.sqlite3_bind_double(handle, dbHandle, index, value.valueOf()); } else if (value instanceof RegExp) { doBindParameter = () => bindings.sqlite3_bind_text(handle, dbHandle, index, value.toString()); } else { throw new Error('Unimplemented'); } doBindParameter(); this._bound = true; } } reset(callback?: ErrorCallback) { this._tick(callback || NOOP, () => { const state = this._getState(); if (state == null) { callback && callback(null); return; } try { // TODO: handle db gone bindings.sqlite3_reset(state.handle, this._db._getHandle()!); } catch { // TODO: swallow error? API docs say this never fails... } state.done = false; callback && callback(null); }); return this; } finalize(callback?: ErrorCallback) { this._queue.onDone(() => { let handle; try { handle = this._getHandle(); } catch { // Do nothing return; } // TODO: handle DB bindings.sqlite3_finalize(handle, this._db._getHandle()!); delete this._gcHandle.handle; this._state = { status: 'finalized' }; callback && callback(null); }); } run(params: Parameter[], callback?: RunCallback): this; run(params: { [key: string]: Parameter }, callback?: RunCallback): this; run(...params: unknown[]): this; run(...params: unknown[]): this { const noopWithThis = function () { /* NOOP */ }; const callback: RunCallback = typeof last(params) === 'function' ? (params.pop() as RunCallback) : noopWithThis; this._tick( (error) => callback.call(this, error), () => { const state = this._getState(); bindings.sqlite3_reset(state.handle, this._db._getHandle()!); state.done = false; this._bind(params); this._tryStep(); this.lastID = bindings.sqlite3_last_insert_rowid(this._db._getHandle()!); this.changes = bindings.sqlite3_changes(this._db._getHandle()!); callback.call(this, null); } ); return this; } get(params: Parameter[], callback?: GetCallback): this; get(params: { [key: string]: Parameter }, callback?: GetCallback): this; get(...params: unknown[]): this; get(...params: unknown[]): this { const callback: GetCallback = typeof last(params) === 'function' ? (params.pop() as GetCallback) : NOOP; this._tick(callback, () => { const state = this._getState(); const parsed = parseParams(params); if (parsed.params.length > 0) { bindings.sqlite3_reset(state.handle, this._db._getHandle()!); state.done = false; } this._doBind(parsed); const hasRow = this._tryStep(); if (!hasRow) { callback(null); return; } const row: { [key: string]: any } = readRow(state.handle); callback(null, row); }); return this; } all(params: Parameter[], callback?: AllCallback): this; all(params: { [key: string]: Parameter }, callback?: AllCallback): this; all(...params: unknown[]): this; all(...params: unknown[]): this { const callback: AllCallback = typeof last(params) === 'function' ? (params.pop() as AllCallback) : NOOP; this._tick(callback, () => { const state = this._getState(); bindings.sqlite3_reset(state.handle, this._db._getHandle()!); state.done = false; this._bind(params); const rows = []; while (this._tryStep()) { const row: { [key: string]: any } = readRow(state.handle); rows.push(row); } callback(null, rows); }); return this; } each(params: Parameter[], callback: GetCallback, complete?: CompleteCallback): this; each(params: Parameter[], callback?: GetCallback): this; each(params: { [key: string]: Parameter }, callback: GetCallback, complete?: CompleteCallback): this; each(params: { [key: string]: Parameter }, callback?: GetCallback): this; each(...params: unknown[]) { const lastItem = last(params); const nextToLast = params[params.length - 2]; let originalCallback: GetCallback = NOOP; let originalComplete: CompleteCallback = NOOP; if (typeof lastItem === 'function' && typeof nextToLast === 'function') { originalComplete = params.pop() as CompleteCallback; originalCallback = params.pop() as GetCallback; } else if (typeof lastItem === 'function') { originalCallback = params.pop() as GetCallback; } const complete: CompleteCallback = (error, rows) => { if (error != null && (error as IgnoreError).ignore) { return; } originalComplete(error, rows); }; const callback: GetCallback = (error, row) => { if (error != null && (error as IgnoreError).ignore) { return; } originalCallback(error, row); }; let rows = 0; this._push(() => { let state; try { state = this._getState(); } catch (error: any) { complete(error); return; } try { // TODO: only here? always? bindings.sqlite3_reset(state.handle, this._db._getHandle()!); state.done = false; } catch (error: any) { complete(error); return; } try { this._bind(params); } catch (error: any) { complete(error); return; } fetchRow(); }); const fetchRow = () => { const onError: ErrorCallback = rows > 0 ? callback : complete; let handle; try { handle = this._getHandle(); } catch (error: any) { onError(error); return; } let hasRow; try { hasRow = this._tryStep(); } catch (error: any) { onError(error); return; } if (!hasRow) { complete(null, rows); return; } const row: { [key: string]: any } = readRow(handle); rows++; this._push(fetchRow); callback(null, row); }; return this; } private _getHandle(): number { return this._getState()?.handle; } private _getState(): { handle: number; done: boolean } { if (this._state.status === 'init') { return this._state; } else if (this._state.status === 'new') { /* * The only way to get here is if preparation failed, which should ignore any further * operations. */ const error: IgnoreError = new Error('Preparation failed'); error.ignore = true; throw error; } else { throw sqlite3.sqliteError('SQLITE_MISUSE', 'Statement is already finalized', sqlite3.MISUSE); } } private _tryStep(): boolean { const state = this._getState(); if (state.done) { return false; } const hasRows = bindings.sqlite3_step(state.handle, this._db._getHandle()!); state.done = state.done || !hasRows; return hasRows; } _tick(callback: ErrorCallback, task: () => void) { tick(() => { try { task(); } catch (error: any) { if (error != null && (error as IgnoreError).ignore) { return; } callback(error); } }); } _push(task: () => void) { const taskDone = this._db._addTask(); this._queue.push(() => { try { task(); } finally { taskDone(); } }); } } function parseParams(params: unknown[]) { let bindParams: [string, Parameter][]; let isMap = false; if (params.length !== 1) { bindParams = Object.entries(params as Parameter[]); } else if (Array.isArray(params[0])) { bindParams = Object.entries(params[0] as Parameter[]); } else if (params[0] == null) { bindParams = []; } else if (typeof params[0] === 'object' && !isValidParam(params[0]!)) { isMap = true; bindParams = Object.entries(params[0] as { [key: string]: Parameter }); } else { bindParams = [['0', params[0] as Parameter]]; } return { params: bindParams, isMap }; } function readRow(handle: number) { const row: { [key: string]: any } = {}; const count = bindings.sqlite3_column_count(handle); for (let index = 0; index < count; index++) { const name = bindings.sqlite3_column_name(handle, index); const type = bindings.sqlite3_column_type(handle, index); let value; // TODO: blob switch (type) { case sqlite3.INTEGER: { // TODO: int64 value = bindings.sqlite3_column_double(handle, index); break; } case sqlite3.TEXT: { value = bindings.sqlite3_column_text(handle, index); break; } case sqlite3.FLOAT: { value = bindings.sqlite3_column_double(handle, index); break; } case sqlite3.NULL: { value = null; break; } default: { throw new Error('Unexpected type: ' + type); } } row[name] = value; } return row; } function isValidParam(param: object) { return param instanceof Date || param instanceof RegExp || Buffer.isBuffer(param); }