UNPKG

@softvisio/core

Version:
635 lines (485 loc) • 18.5 kB
import "#lib/result"; import crypto from "node:crypto"; import path from "node:path"; import { fileURLToPath } from "node:url"; import Sqlite from "@softvisio/sqlite"; import mixins from "#lib/mixins"; import Numeric from "#lib/numeric"; import constants from "#lib/sql/constants"; import Dbh from "#lib/sql/dbh"; import { sql } from "#lib/sql/query"; import { encodeDate, sqliteDecoders } from "#lib/sql/types"; import uuid from "#lib/uuid"; import Schema from "./sqlite/schema.js"; import Transactions from "./sqlite/transactions.js"; const MAX_PARAMS = 999; const DEFAULT_CACHE = "private"; // "shared", "private" const DEFAULT_MODE = "rwc"; // "ro", "rw", "rwc", "memory" const DEFAULT_TEMP_STORE = "memory"; // "file", "memory" const DEFAULT_JOURNAL_MODE = "wal"; // "delete", "truncate", "persist", "memory", "wal", "off". "wal" is the best. const DEFAULT_SYNCHRONOUS = "off"; // "full", "normal", "off". "off" - data integrity on app failure, "normal" - data integrity on app and OS failures, "full" - full data integrity on app or OS failures, slower const DEFAULT_CACHE_SIZE = -1_048_576; // integer, 0+ - pages, -kilobytes, default 1G const DEFAULT_BUSY_TIMEOUT = 30_000; // 30 sec. // NOTE https://github.com/JoshuaWise/better-sqlite3/blob/master/docs/api.md const SQL = { "publishQuery": sql`SELECT sqlite_notify( ?, ? )`.prepare(), }; export default class DbhPoolSqlite extends mixins( Transactions, Dbh ) { #url; #sqlite; #prepared = {}; #schema = new Schema( this ); #destroyed = false; #decode = { ...sqliteDecoders }; constructor ( url, options = {} ) { super(); [ this.#url, options ] = this.#parseFileUrl( url, options ); this.#sqlite = new Sqlite( this.#url, { "timeout": options.busyTimeout, } ); this.#sqlite.defaultSafeIntegers( true ); this.pragma( `encoding = "UTF-8"` ); this.pragma( `temp_store = ${ options.tempStore }` ); this.pragma( `journal_mode = ${ options.journalMode }` ); this.pragma( `synchronous = ${ options.synchronous }` ); this.pragma( `cache_size = ${ options.cacheSize }` ); // create custom functions this.function( "sqlite_notify", ( name, data ) => { // reserved event if ( constants.reservedEvents.has( name ) ) { if ( this.schema.isLoaded ) throw new Error( `SQLite event name "${ name }" is reserved` ); else return; } // unknown event if ( this.schema.isLoaded && !this.schema.isEventValid( name ) ) throw new Error( `SQLite event name "${ name }" is unknown` ); process.nextTick( () => this.emit( name, data ) ); } ); this.function( "gen_random_uuid", () => { return uuid(); } ); this.function( "gen_random_uuid", () => { return uuid(); } ); this.function( "md5", str => { if ( str == null ) return null; return crypto.createHash( "MD5" ).update( str ).digest( "hex" ); } ); this.function( "time_hires", () => { return Date.now() / 1000; } ); // watch for notifications subscribe this.watch( this.#watcher.bind( this ) ); } // properties get isSqlite () { return true; } get url () { return this.#url; } get inTransaction () { return this.#sqlite.inTransaction; } get schema () { return this.#schema; } get isDestroyed () { return this.#destroyed; } // public async start () { return this.schema.cron.start(); } async stop () { return this.schema.cron.stop(); } async destroy () { if ( this.destroyed ) return; this.#destroyed = true; await this.stop(); this.#sqlite.close(); } toString () { return this.url; } toJSON () { return this.url; } quote ( value ) { // null if ( value == null ) { return "NULL"; } else { // string if ( typeof value === "string" ) { // if value contains ZERO character // ignore, to make it comatible with postgres, where 0x00 in strings is disabled // if ( param.indexOf( "\0" ) > -1 ) { // return "CAST(x'" + Buffer.from( param ).toString( "hex" ) + "' AS TEXT)"; // } // else { return "'" + value.replaceAll( "'", "''" ) + "'"; // } } // number else if ( typeof value === "number" ) { return value; } // boolean else if ( typeof value === "boolean" ) { return value === true ? "TRUE" : "FALSE"; } // bigint else if ( typeof value === "bigint" ) { return value.toString(); } // object else if ( typeof value === "object" ) { // buffer if ( Buffer.isBuffer( value ) ) { return "x'" + value.toString( "hex" ) + "'"; } // date else if ( value instanceof Date ) { return "'" + encodeDate( value ) + "'"; } // numeric else if ( Numeric.isNumeric( value ) ) { return value.toString(); } // object else { return "'" + JSON.stringify( value ).replaceAll( "'", "''" ) + "'"; } } // invalid type else { throw TypeError( `Unsupported SQL parameter type "${ value }"` ); } } } attach ( name, url, options = {} ) { [ url, options ] = this.#parseFileUrl( url, options ); this.do( `ATTACH DATABASE '${ url }' AS "${ name }"` ); this.pragma( `${ name }.encoding = "UTF-8"` ); this.pragma( `${ name }.temp_store = ${ options.tempStore }` ); this.pragma( `${ name }.journal_mode = ${ options.journalMode }` ); this.pragma( `${ name }.synchronous = ${ options.synchronous }` ); this.pragma( `${ name }.cache_size = ${ options.cacheSize }` ); } pragma ( pragma, options = {} ) { return this.#sqlite.pragma( pragma, options ); } function ( name, options, callback ) { return this.#sqlite.function( name, options, callback ); } aggregate ( name, options ) { return this.#sqlite.aggregate( name, options ); } // query exec ( query, params ) { if ( params && !Array.isArray( params ) ) params = params.params; const sth = this.#prepareQuery( query, params, true ); var res; try { this.#sqlite.exec( sth.query ); res = result( 200 ); } catch ( e ) { res = this._onQueryError( e, sth.query ); } return res; } do ( query, params ) { const res = this.#do( query, params, { "ignoreData": true } ); return res; } select ( query, params ) { const res = this.#do( query, params ); return res; } selectRow ( query, params ) { const res = this.#do( query, params, { "firstRow": true } ); return res; } // private #do ( query, params, options = {} ) { if ( params && !Array.isArray( params ) ) { options = { ...params, ...options }; params = options.params; } query = this.#prepareQuery( query, params ); var sth, decode; // query is prepared if ( query.id && this.#prepared[ query.id ] ) { sth = this.#prepared[ query.id ].sth; decode = this.#prepared[ query.id ].decode; } // sth is not cached if ( !sth ) { // prepare sth try { sth = this.#sqlite.prepare( query.query ); } catch ( e ) { return this._onQueryError( e, query.query ); } if ( sth.reader ) { let _decode; for ( const column of sth.columns() ) { let type; // get type from query.decode by column name if ( query.types?.[ column.name ] ) { type = query.types[ column.name ]; // invalid column decode type if ( typeof type === "string" && !this.#decode[ type ] ) return this._onQueryError( new Error( `Invalid decode type for column "${ column.name }"` ), query.query ); } // get type by column type else { type = column.type; } if ( typeof type === "string" ) { type = this.#decode[ type ]; } if ( type ) { _decode ||= {}; _decode[ column.name ] = type; } } if ( _decode ) decode = _decode; } // cache sth if ( query.id ) { this.#prepared[ query.id ] = { sth, decode, }; } } // check correct dbh method usage if ( options.ignoreData ) { if ( sth.reader ) throw `Invalid usage, you need to execute query, that returns data, using "select" method, ` + query.query; } else if ( !sth.reader ) { throw `Invalid usage, you need to execute query, that returns no data, using "do" method, ` + query.query; } const ignoreData = options.ignoreData || !sth.reader; try { let data, res; const method = ignoreData ? "run" : options.firstRow ? "get" : "all"; if ( query.params ) { data = sth[ method ]( this.#prepareParams( query.params ) ); } else { data = sth[ method ](); } // "do" request if ( ignoreData ) { res = result( 200, null ); res.meta.rows = data.changes; } // "select" request else { res = result( 200 ); if ( !options.firstRow && !data.length ) data = null; if ( data ) { if ( options.firstRow ) data = [ data ]; // decode columns for ( const row of data ) { for ( const name in row ) { if ( row[ name ] == null ) continue; // decode with column decoder if ( decode?.[ name ] ) { row[ name ] = decode[ name ]( row[ name ] ); } // decode bigint else if ( typeof row[ name ] === "bigint" ) { // decode bigint to number row[ name ] = Number( row[ name ] ); // bigint is too big for number if ( !Number.isSafeInteger( row[ name ] ) ) { throw RangeError( `Number in column "${ name }" is out of range` ); } } } } res.meta.rows = data.length; if ( options.firstRow ) data = data[ 0 ]; res.data = data; } else { res.meta.rows = 0; } } return res; } catch ( e ) { return this._onQueryError( e, query.query ); } } #prepareQuery ( query, params, toString ) { var sth; if ( typeof query === "string" ) { query = sql( query ); } else { params ||= query.params; } if ( !params?.length ) params = null; if ( params && ( toString || params.length > MAX_PARAMS ) ) { sth = { "query": this.queryToString( query, params ), "types": query.types, }; } else { sth = { "id": query.id, "query": query.sqliteQuery, params, "types": query.types, }; } return sth; } #prepareParams ( params ) { const _params = []; for ( const param of params ) { // null if ( param == null ) { _params.push( null ); } else { if ( typeof param === "boolean" ) { _params.push( param === true ? 1 : 0 ); } // object else if ( typeof param === "object" ) { // buffer if ( param instanceof Buffer ) { _params.push( param ); } // date else if ( param instanceof Date ) { _params.push( encodeDate( param ) ); } // numeric else if ( Numeric.isNumeric( param ) ) { _params.push( param.toString() ); } // object else { _params.push( JSON.stringify( param ) ); } } // add as is else { _params.push( param ); } } } return _params; } // notifications #watcher ( name, subscribe ) { if ( !subscribe ) return; // reserved event if ( constants.reservedEvents.has( name ) ) return; if ( this.schema.isLoaded && !this.schema.isEventValid( name ) ) throw `Event name "${ name }" is not emitted by the database`; } get isConnected () { return true; } waitConnect () { return; } publish ( name, data ) { this.select( SQL.publishQuery, [ name, data ] ); } #parseFileUrl ( url, options = {} ) { options = { ...options }; var relative; if ( url instanceof URL ) { url = new URL( url ); } else { // url string if ( url.startsWith( "file:" ) ) { // relative if ( !url.startsWith( "file:/" ) ) { url = new URL( url ); if ( url.pathname !== "/" && url.pathname !== "/:memory:" ) { relative = true; url.pathname = path.join( process.cwd(), url.pathname ); } } // absolute else { url = new URL( url ); } } // path else { if ( !path.isAbsolute( url ) && url !== ":memory:" ) { relative = true; url = path.join( process.cwd(), url ); } url = new URL( "/" + url, "file:" ); } } // not a file url if ( url.protocol !== "file:" ) { throw new Error( "Invalid SQL protocol" ); } // cache options.cache ||= url.searchParams.get( "cache" ) || DEFAULT_CACHE; // mode options.mode ||= url.searchParams.get( "mode" ) || DEFAULT_MODE; // tempStore options.tempStore ??= url.searchParams.get( "tempStore" ) || DEFAULT_TEMP_STORE; // journalMode options.journalMode ??= url.searchParams.get( "journalMode" ) || DEFAULT_JOURNAL_MODE; // synchronous options.synchronous ??= url.searchParams.get( "synchronous" ) || DEFAULT_SYNCHRONOUS; // cacheSize options.cacheSize ||= +( url.searchParams.get( "cacheSize" ) || DEFAULT_CACHE_SIZE ); if ( !Number.isInteger( options.cacheSize ) ) throw TypeError( "SQLite cacheSize is invalid" ); // busyTimeout options.busyTimeout ||= +( url.searchParams.get( "busyTimeout" ) || DEFAULT_BUSY_TIMEOUT ); if ( !Number.isInteger( options.busyTimeout ) || options.busyTimeout <= 0 ) throw TypeError( "SQLite busyTimeout is invalid" ); const urlSearchParams = new URLSearchParams(); if ( options.cache !== DEFAULT_CACHE ) urlSearchParams.set( "cache", options.cache ); if ( options.tempStore !== DEFAULT_TEMP_STORE ) urlSearchParams.set( "tempStore", options.tempStore ); if ( options.journalMode !== DEFAULT_JOURNAL_MODE ) urlSearchParams.set( "journalMode", options.journalMode ); if ( options.synchronous !== DEFAULT_SYNCHRONOUS ) urlSearchParams.set( "synchronous", options.synchronous ); if ( options.cacheSize !== DEFAULT_CACHE_SIZE ) urlSearchParams.set( "cacheSize", options.cacheSize ); if ( options.busyTimeout !== DEFAULT_BUSY_TIMEOUT ) urlSearchParams.set( "busyTimeout", options.busyTimeout ); if ( options.mode !== DEFAULT_MODE && options.mode !== "memory" ) urlSearchParams.set( "mode", options.mode ); urlSearchParams.sort(); // temp if ( url.pathname === "/" ) { url = "file:"; } // memory else if ( url.pathname === "/:memory:" || options.mode === "memory" ) { url = "file::memory:"; } else if ( relative ) { url = "file:" + path.relative( process.cwd(), fileURLToPath( url ) ).replaceAll( "\\", "/" ); } else { url = "file:" + url.pathname; } if ( urlSearchParams.size ) url += "?" + urlSearchParams.toString(); return [ url, options ]; } }