Jofthomas's picture
Jofthomas HF staff
Upload 4781 files
5c2ed06 verified
/**
* Async worker thread wrapper around SQLite, written to improve concurrent performance.
* @author mia-pi-git
*/
import { QueryProcessManager } from './process-manager';
import type * as sqlite from 'better-sqlite3';
import { FS } from './fs';
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore in case not installed
import type { SQLStatement } from 'sql-template-strings';
export const DB_NOT_FOUND = null;
export interface SQLOptions {
file: string;
/** file to import database functions from - this should be relative to this filename. */
extension?: string;
/** options to be passed to better-sqlite3 */
sqliteOptions?: sqlite.Options;
/**
* You can choose to return custom error information, or just crashlog and return void.
* doing that will make it reject in main as normal.
* (it only returns a diff result if you do, otherwise it's a default error).
* You can also choose to only handle errors in the parent - see the third param, isParentProcess.
*/
onError?: ErrorHandler;
}
type DataType = unknown[] | Record<string, unknown>;
export type SQLInput = string | number | null;
export interface ResultRow { [k: string]: SQLInput }
export interface TransactionEnvironment {
db: sqlite.Database;
statements: Map<string, sqlite.Statement>;
}
export type DatabaseQuery = {
/** Prepare a statement - data is the statement. */
type: 'prepare', data: string,
} | {
/** Get all lines from a statement. Data is the params. */
type: 'all', data: DataType, statement: string, noPrepare?: boolean,
} | {
/** Execute raw SQL in the database. */
type: "exec", data: string,
} | {
/** Get one line from a prepared statement. */
type: 'get', data: DataType, statement: string, noPrepare?: boolean,
} | {
/** Run a prepared statement. */
type: 'run', data: DataType, statement: string, noPrepare?: boolean,
} | {
type: 'transaction', name: string, data: DataType,
} | {
type: 'start', options: SQLOptions,
} | {
type: 'load-extension', data: string,
};
type ErrorHandler = (error: Error, data: DatabaseQuery, isParentProcess: boolean) => any;
function getModule() {
try {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore does not exist while building NPM package???
return require('better-sqlite3') as typeof sqlite.default;
} catch {
return null;
}
}
export class Statement<R extends DataType = DataType, T = any> {
private db: SQLDatabaseManager;
private statement: string;
constructor(statement: string, db: SQLDatabaseManager) {
this.db = db;
this.statement = statement;
}
run(data: R) {
return this.db.run(this.statement, data);
}
all(data: R) {
return this.db.all<T>(this.statement, data);
}
get(data: R) {
return this.db.get<T>(this.statement, data);
}
toString() {
return this.statement;
}
toJSON() {
return this.statement;
}
}
export class SQLDatabaseManager extends QueryProcessManager<DatabaseQuery, any> {
options: SQLOptions;
database: null | sqlite.Database = null;
state: {
transactions: Map<string, sqlite.Transaction>,
statements: Map<string, sqlite.Statement>,
};
private dbReady = false;
constructor(module: NodeJS.Module, options: SQLOptions) {
super(module, query => {
if (!this.dbReady) {
this.setupDatabase();
}
try {
switch (query.type) {
case 'load-extension': {
if (!this.database) return null;
this.loadExtensionFile(query.data);
return true;
}
case 'transaction': {
const transaction = this.state.transactions.get(query.name);
// !transaction covers db not existing, typically, but this is just to appease ts
if (!transaction || !this.database) {
return null;
}
const env: TransactionEnvironment = {
db: this.database,
statements: this.state.statements,
};
return transaction(query.data, env) || null;
}
case 'exec': {
if (!this.database) return { changes: 0 };
this.database.exec(query.data);
return true;
}
case 'get': {
if (!this.database) {
return null;
}
return this.extractStatement(query).get(query.data);
}
case 'run': {
if (!this.database) {
return null;
}
return this.extractStatement(query).run(query.data);
}
case 'all': {
if (!this.database) {
return null;
}
return this.extractStatement(query).all(query.data);
}
case 'prepare':
if (!this.database) {
return null;
}
this.state.statements.set(query.data, this.database.prepare(query.data));
return query.data;
}
} catch (error: any) {
return this.onError(error, query);
}
});
this.options = options;
this.state = {
transactions: new Map(),
statements: new Map(),
};
if (!this.isParentProcess) this.setupDatabase();
}
private onError(err: Error, query: DatabaseQuery) {
if (this.options.onError) {
const result = this.options.onError(err, query, false);
if (result) return result;
}
return {
queryError: {
stack: err.stack,
message: err.message,
query,
},
};
}
private cacheStatement(source: string) {
source = source.trim();
let statement = this.state.statements.get(source);
if (!statement) {
statement = this.database!.prepare(source);
this.state.statements.set(source, statement);
}
return statement;
}
registerFunction(key: string, cb: (...args: any) => any) {
this.database!.function(key, cb);
}
private extractStatement(
query: DatabaseQuery & { statement: string, noPrepare?: boolean }
) {
query.statement = query.statement.trim();
const statement = query.noPrepare ?
this.state.statements.get(query.statement) :
this.cacheStatement(query.statement);
if (!statement) throw new Error(`Missing cached statement "${query.statement}" where required`);
return statement;
}
setupDatabase() {
if (this.dbReady) return;
this.dbReady = true;
const { file, extension } = this.options;
const Database = getModule();
this.database = Database ? new Database(file) : null;
if (extension) this.loadExtensionFile(extension);
}
loadExtensionFile(extension: string) {
return this.handleExtensions(require('../' + extension));
}
handleExtensions(imports: any) {
if (!this.database) return;
const {
functions,
transactions: storedTransactions,
statements: storedStatements,
onDatabaseStart,
} = imports;
// migrations usually are run here, so this needs to be first
if (onDatabaseStart) {
onDatabaseStart.call(this, this.database);
}
if (functions) {
for (const k in functions) {
this.registerFunction(k, functions[k]);
}
}
if (storedTransactions) {
for (const t in storedTransactions) {
const transaction = this.database.transaction(storedTransactions[t]);
this.state.transactions.set(t, transaction);
}
}
if (storedStatements) {
for (const k in storedStatements) {
const statement = this.database.prepare(storedStatements[k]);
this.state.statements.set(statement.source, statement);
}
}
}
async query(input: DatabaseQuery) {
const result = await super.query(input);
if (result?.queryError) {
const err = new Error(result.queryError.message);
err.stack = result.queryError.stack;
if (this.options.onError) {
const errResult = this.options.onError(err, result.queryError.query, true);
if (errResult) return errResult;
}
throw err;
}
return result;
}
all<T = any>(
statement: string | Statement, data: DataType = [], noPrepare?: boolean
): Promise<T[]> {
if (typeof statement !== 'string') statement = statement.toString();
return this.query({ type: 'all', statement, data, noPrepare });
}
get<T = any>(
statement: string | Statement, data: DataType = [], noPrepare?: boolean
): Promise<T> {
if (typeof statement !== 'string') statement = statement.toString();
return this.query({ type: 'get', statement, data, noPrepare });
}
run(
statement: string | Statement, data: DataType = [], noPrepare?: boolean
): Promise<sqlite.RunResult> {
if (typeof statement !== 'string') statement = statement.toString();
return this.query({ type: 'run', statement, data, noPrepare });
}
transaction<T = any>(name: string, data: DataType = []): Promise<T> {
return this.query({ type: 'transaction', name, data });
}
async prepare(statement: string): Promise<Statement | null> {
const source = await this.query({ type: 'prepare', data: statement });
if (!source) return null;
return new Statement(source, this);
}
exec(data: string): Promise<{ changes: number }> {
return this.query({ type: 'exec', data });
}
loadExtension(filepath: string) {
return this.query({ type: 'load-extension', data: filepath });
}
async runFile(file: string) {
const contents = await FS(file).read();
return this.query({ type: 'exec', data: contents });
}
}
export const tables = new Map<string, DatabaseTable<any>>();
export class DatabaseTable<T> {
database: SQLDatabaseManager;
name: string;
primaryKeyName: string;
constructor(
name: string,
primaryKeyName: string,
database: SQLDatabaseManager
) {
this.name = name;
this.database = database;
this.primaryKeyName = primaryKeyName;
tables.set(this.name, this);
}
async selectOne<R = T>(
entries: string | string[],
where?: SQLStatement
): Promise<R | null> {
const query = where || SQL.SQL``;
query.append(' LIMIT 1');
const rows = await this.selectAll<R>(entries, query);
return rows?.[0] || null;
}
selectAll<R = T>(
entries: string | string[],
where?: SQLStatement
): Promise<R[]> {
const query = SQL.SQL`SELECT `;
if (typeof entries === 'string') {
query.append(` ${entries} `);
} else {
for (let i = 0; i < entries.length; i++) {
query.append(entries[i]);
if (typeof entries[i + 1] !== 'undefined') query.append(', ');
}
query.append(' ');
}
query.append(`FROM ${this.name} `);
if (where) {
query.append(' WHERE ');
query.append(where);
}
return this.all<R>(query);
}
get(entries: string | string[], keyId: SQLInput) {
const query = SQL.SQL``;
query.append(this.primaryKeyName);
query.append(SQL.SQL` = ${keyId}`);
return this.selectOne(entries, query);
}
updateAll(toParams: Partial<T>, where?: SQLStatement, limit?: number) {
const to = Object.entries(toParams);
const query = SQL.SQL`UPDATE `;
query.append(this.name + ' SET ');
for (let i = 0; i < to.length; i++) {
const [k, v] = to[i];
query.append(`${k} = `);
query.append(SQL.SQL`${v}`);
if (typeof to[i + 1] !== 'undefined') {
query.append(', ');
}
}
if (where) {
query.append(` WHERE `);
query.append(where);
}
if (limit) query.append(SQL.SQL` LIMIT ${limit}`);
return this.run(query);
}
updateOne(to: Partial<T>, where?: SQLStatement) {
return this.updateAll(to, where, 1);
}
deleteAll(where?: SQLStatement, limit?: number) {
const query = SQL.SQL`DELETE FROM `;
query.append(this.name);
if (where) {
query.append(' WHERE ');
query.append(where);
}
if (limit) {
query.append(SQL.SQL` LIMIT ${limit}`);
}
return this.run(query);
}
delete(keyEntry: SQLInput) {
const query = SQL.SQL``;
query.append(this.primaryKeyName);
query.append(SQL.SQL` = ${keyEntry}`);
return this.deleteOne(query);
}
deleteOne(where: SQLStatement) {
return this.deleteAll(where, 1);
}
insert(colMap: Partial<T>, rest?: SQLStatement, isReplace = false) {
const query = SQL.SQL``;
query.append(`${isReplace ? 'REPLACE' : 'INSERT'} INTO ${this.name} (`);
const keys = Object.keys(colMap);
for (let i = 0; i < keys.length; i++) {
query.append(keys[i]);
if (typeof keys[i + 1] !== 'undefined') query.append(', ');
}
query.append(') VALUES (');
for (let i = 0; i < keys.length; i++) {
const key = keys[i];
query.append(SQL.SQL`${colMap[key as keyof T]}`);
if (typeof keys[i + 1] !== 'undefined') query.append(', ');
}
query.append(') ');
if (rest) query.append(rest);
return this.database.run(query.sql, query.values);
}
replace(cols: Partial<T>, rest?: SQLStatement) {
return this.insert(cols, rest, true);
}
update(primaryKey: SQLInput, data: Partial<T>) {
const query = SQL.SQL``;
query.append(this.primaryKeyName + ' = ');
query.append(SQL.SQL`${primaryKey}`);
return this.updateOne(data, query);
}
// catch-alls for "we can't fit this query into any of the wrapper functions"
run(sql: SQLStatement) {
return this.database.run(sql.sql, sql.values) as Promise<{ changes: number }>;
}
all<R = T>(sql: SQLStatement) {
return this.database.all<R>(sql.sql, sql.values);
}
}
function getSQL(
module: NodeJS.Module, input: SQLOptions & { processes?: number }
) {
const { processes } = input;
const PM = new SQLDatabaseManager(module, input);
if (PM.isParentProcess) {
if (processes) PM.spawn(processes);
}
return PM;
}
export const SQL = Object.assign(getSQL, {
DatabaseTable,
SQLDatabaseManager,
tables,
SQL: (() => {
try {
return require('sql-template-strings');
} catch {
return () => {
throw new Error("Using SQL-template-strings without it installed");
};
}
})() as typeof import('sql-template-strings').SQL,
});
export declare namespace SQL {
export type DatabaseManager = import('./sql').SQLDatabaseManager;
export type Statement = import('./sql').Statement;
export type Options = import('./sql').SQLOptions;
// eslint-disable-next-line @typescript-eslint/no-shadow
export type TransactionEnvironment = import('./sql').TransactionEnvironment;
export type Query = import('./sql').DatabaseQuery;
export type DatabaseTable<T> = import('./sql').DatabaseTable<T>;
}