From: Justin Wind Date: Thu, 11 Jan 2024 19:34:47 +0000 (-0800) Subject: update the abstraction extension model X-Git-Url: https://git.squeep.com/?a=commitdiff_plain;h=254e6adf1c0d3238a7fceffd85cc503feefbefdf;p=squeep-db-helper update the abstraction extension model --- diff --git a/README.md b/README.md index 568c26d..fcbcc9b 100644 --- a/README.md +++ b/README.md @@ -8,3 +8,8 @@ Helper classes, utilities, and opinions for supporting multiple database engines - A simple automated schema migration system is provided. See files in `test-integration` for example implementation. + +Your database interface is defined by extending the Abstract class. + +Your database engines are implemented by extending a class created by passing your abstract interface class to an engine class creator function. + diff --git a/index.js b/index.js index 6a55f64..2aeb924 100644 --- a/index.js +++ b/index.js @@ -4,14 +4,14 @@ const Abstract = require('./lib/abstract'); const Errors = require('./lib/errors'); const Factory = require('./lib/factory'); const SchemaVersionHelper = require('./lib/schema-version-helper'); -const Postgres = require('./lib/postgres'); -const SQLite = require('./lib/sqlite'); +const PostgresCreator = require('./lib/postgres-creator'); +const SQLiteCreator = require('./lib/sqlite-creator'); module.exports = { Abstract, Errors, Factory, SchemaVersionHelper, - Postgres, - SQLite, -}; \ No newline at end of file + SQLiteCreator, + PostgresCreator, +}; diff --git a/lib/factory.js b/lib/factory.js index d232a7c..acaf47f 100644 --- a/lib/factory.js +++ b/lib/factory.js @@ -14,11 +14,12 @@ class DatabaseFactory { * @param {...any} engineArguments * @returns {Database} */ - constructor(logger, options, ...engineArguments) { + constructor(Abstract, logger, options, ...engineArguments) { const connectionString = options.db.connectionString || ''; const protocol = connectionString.slice(0, connectionString.indexOf('://')).toLowerCase(); - const Engine = DatabaseFactory.requireEngine(protocol); + const EngineCreator = DatabaseFactory.requireEngineCreator(protocol); + const Engine = EngineCreator(Abstract); return new Engine(logger, options, ...engineArguments); } @@ -28,14 +29,14 @@ class DatabaseFactory { * @param {String} protocol * @returns {Object} */ - static requireEngine(protocol) { + static requireEngineCreator(protocol) { const Engines = DatabaseFactory.Engines; switch (protocol) { case Engines.PostgreSQL: - return require('./postgres'); + return require('./postgres-creator'); case Engines.SQLite: - return require('./sqlite'); + return require('./sqlite-creator'); default: throw new UnsupportedEngine(protocol); diff --git a/lib/postgres-creator.js b/lib/postgres-creator.js new file mode 100644 index 0000000..29c6fe6 --- /dev/null +++ b/lib/postgres-creator.js @@ -0,0 +1,292 @@ +'use strict'; + +const PGPromise = require('pg-promise'); +const path = require('node:path'); +const DBErrors = require('./errors'); +const { unappliedSchemaVersions } = require('./schema-version-helper'); +const { fileScope } = require('@squeep/log-helper'); + +const _fileScope = fileScope(__filename); + +const PGTypeId = { + INT8: 20, // Type Id 20 == INT8 (BIGINT) + INT8Array: 1016, // Type Id 1016 == INT8[] (BIGINT[]) +}; + +const PostgresCreator = (Abstract) => { + + class DatabasePostgres extends Abstract { + _pgpInitOptions = { + capSQL: true, + }; + + + // eslint-disable-next-line class-methods-use-this + get schemaVersionsSupported() { + return { + min: { + major: 0, + minor: 0, + patch: 0, + }, + max: { + major: 0, + minor: 0, + patch: 0, + }, + }; + } + + + /** + * + * @param {ConsoleLike} logger + * @param {Object} options + * @param {Object} options.db + * @param {String} options.db.connectionString + * @param {String} options.db.queryLogLevel + * @param {Boolean} options.db.noWarnings set when running tests + * @param {} options.db. + * @param {*} pgpStub for tests + */ + constructor(logger, options, pgpStub) { + super(logger, options); + + // Set up + this._pgpInitOptionsConfigure(options.db); + + // istanbul ignore next + this._pgp = pgpStub || PGPromise(this._pgpInitOptions); + + // Return native BigInts + this._pgp.pg.types.setTypeParser(PGTypeId.INT8, BigInt); // Type Id 20 = INT8 (BIGINT) + const parseBigIntArray = this._pgp.pg.types.getTypeParser(PGTypeId.INT8Array); // Type Id 1016 = INT8[] (BIGINT[]) + this._pgp.pg.types.setTypeParser(PGTypeId.INT8Array, (a) => parseBigIntArray(a).map(BigInt)); + + // Establish db connection + this.db = this._pgp(options.db.connectionString); + + // Suppress QueryFile warnings when running tests + this.noWarnings = options.db.noWarnings; + } + + + async initialize(sqlPath = __dirname, applyMigrations = true) { + const _scope = _fileScope('initialize'); + this.logger.debug(_scope, 'called', { applyMigrations }); + if (applyMigrations) { + await this._initTables(sqlPath); + } + this._initStatements(sqlPath); + await super.initialize(); + } + + + async _tableExists(tableName, schema) { + if (schema) { + return this.db.oneOrNone('SELECT table_name FROM information_schema.tables WHERE table_name=$(tableName) AND table_schema=$(schema)', { tableName, schema }); + } else { + return this.db.oneOrNone('SELECT table_name FROM information_schema.tables WHERE table_name=$(tableName)', { tableName }); + } + } + + /** + * Create schema version tracking table and other common stuff. + */ + async _createMetaVersionTable() { + const schema = this.pgSchema ? `CREATE SCHEMA ${this.pgSchema}; + SET SCHEMA '${this.pgSchema}';` : ''; + return await this.db.multiResult(`BEGIN; +CREATE EXTENSION IF NOT EXISTS pg_stat_statements; +CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; +${schema} +CREATE TABLE IF NOT EXISTS _meta_schema_version ( + major BIGINT NOT NULL, + minor BIGINT NOT NULL, + patch BIGINT NOT NULL, + applied TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), + PRIMARY KEY (major, minor, patch) +); +INSERT INTO _meta_schema_version (major, minor, patch) VALUES (0, 0, 0); +COMMIT;`); + } + + + async _initTables(sqlPath = __dirname) { + const _scope = _fileScope('_initTables'); + this.logger.debug(_scope, 'called', {}); + + const _queryFile = this._queryFileHelper(); + + // Migrations rely upon this table, ensure it exists. + const metaVersionTable = '_meta_schema_version'; + + let metaExists = await this._tableExists(metaVersionTable, this.pgSchema); + if (!metaExists) { + const results = await this._createMetaVersionTable(); + this.logger.debug(_scope, 'created meta version table', { results }); + metaExists = await this._tableExists(metaVersionTable, this.pgSchema); + /* istanbul ignore if */ + if (!metaExists) { + throw new DBErrors.UnexpectedResult(`did not create ${metaVersionTable} table`); + } + this.logger.info(_scope, 'created schema version table', { metaVersionTable }); + } + + // Apply migrations + const currentSchema = await this._currentSchema(); + const migrationsWanted = unappliedSchemaVersions(sqlPath, currentSchema, this.schemaVersionsSupported); + this.logger.debug(_scope, 'schema migrations wanted', { migrationsWanted }); + for (const v of migrationsWanted) { + const fPath = path.join(sqlPath, 'sql', 'schema', v, 'apply.sql'); + const migrationSql = _queryFile(fPath); + const results = await this.db.multiResult(migrationSql); + this.logger.debug(_scope, 'executed migration sql', { version: v, results }); + this.logger.info(_scope, 'applied migration', { version: v }); + } + } + + + /** + * Configure the pgp init options object with assorted logging hooks, + * and converting returned column names from snake_case to camelCase. + * @param {Object} options + * @param {String} options.queryLogLevel + */ + _pgpInitOptionsConfigure({ queryLogLevel }) { + // Log errors + const errorScope = _fileScope('pgp:error'); + const errorLogger = this.logger.error; + + this._pgpInitOptions.error = (err, event) => { + errorLogger(errorScope, '', { err, event }); + }; + + // Log queries + let queryLogger; + if (queryLogLevel) { + const queryScope = _fileScope('pgp:query'); + queryLogger = this.logger[queryLogLevel]; // eslint-disable-line security/detect-object-injection + this._pgpInitOptions.query = (event) => { + queryLogger(queryScope, '', { + query: event?.query, + params: event?.params, + }); + }; + } + + // Deophidiate column names in-place, log results + const resultScope = _fileScope('pgp:result'); + this._pgpInitOptions.receive = ({ data, result, ctx: event }) => { + const exemplaryRow = data[0]; + for (const prop in exemplaryRow) { + const camel = Abstract._camelfy(prop); + if (!(camel in exemplaryRow)) { + for (const d of data) { + d[camel] = d[prop]; // eslint-disable-line security/detect-object-injection + delete d[prop]; // eslint-disable-line security/detect-object-injection + } + } + } + if (queryLogLevel) { + // Omitting .rows + queryLogger(resultScope, '', { + query: event?.query, + command: result?.command, + rowCount: result?.rowCount, + duration: result?.duration, + }); + } + }; + } + + + /** + * Wrap pgp QueryFile to impose settings and log errors. + * @returns {QueryFile} + */ + _queryFileHelper() { + return (file) => { + const _scope = _fileScope('_queryFile'); + /* istanbul ignore next */ + const qfParams = { + minify: true, + ...(this.noWarnings && { noWarnings: this.noWarnings }), + }; + const qf = new this._pgp.QueryFile(file, qfParams); + if (qf.error) { + this.logger.error(_scope, 'failed to create SQL statement', { error: qf.error, file }); + throw qf.error; + } + return qf; + }; + } + + + /** + * Load and prepare SQL from files. + */ + _initStatements(sqlPath = __dirname) { + const _scope = _fileScope('_initStatements'); + const _queryFile = this._queryFileHelper(); + this.statement = this._pgp.utils.enumSql(path.join(sqlPath, 'sql'), {}, _queryFile); + this.logger.debug(_scope, 'statements initialized', { statements: Object.keys(this.statement).length }); + } + + + async _currentSchema() { + return this.db.one(`SELECT major, minor, patch FROM ${this.pgSchema ? (this.pgSchema + '.') : ''}_meta_schema_version ORDER BY major DESC, minor DESC, patch DESC LIMIT 1`); + } + + + async context(fn) { + return this.db.task(async (t) => fn(t)); + } + + + // eslint-disable-next-line class-methods-use-this + async transaction(dbCtx, fn) { + return dbCtx.txIf(async (t) => fn(t)); + } + + + async healthCheck() { + const _scope = _fileScope('healthCheck'); + this.logger.debug(_scope, 'called', {}); + const c = await this.db.connect(); + c.done(); + return { serverVersion: c.client.serverVersion }; + } + + + async _closeConnection() { + const _scope = _fileScope('_closeConnection'); + try { + await this._pgp.end(); + } catch (e) { + this.logger.error(_scope, 'failed', { error: e }); + throw e; + } + } + + + /* istanbul ignore next */ + async _purgeTables(really = false) { + const _scope = _fileScope('_purgeTables'); + try { + if (really) { + await this.db.tx(async (t) => { + await t.batch(this._tableNames.map(async (table) => t.query('TRUNCATE TABLE $(table:name) CASCADE', { table }))); + }); + } + } catch (e) { + this.logger.error(_scope, 'failed', { error: e }); + throw e; + } + } + + } + return DatabasePostgres; +}; + +module.exports = PostgresCreator; \ No newline at end of file diff --git a/lib/postgres.js b/lib/postgres.js deleted file mode 100644 index d0e4091..0000000 --- a/lib/postgres.js +++ /dev/null @@ -1,282 +0,0 @@ -'use strict'; - -const PGPromise = require('pg-promise'); -const path = require('node:path'); -const Database = require('./abstract'); -const DBErrors = require('./errors'); -const { unappliedSchemaVersions } = require('./schema-version-helper'); -const { fileScope } = require('@squeep/log-helper'); - -const _fileScope = fileScope(__filename); - -const PGTypeId = { - INT8: 20, // Type Id 20 == INT8 (BIGINT) - INT8Array: 1016, // Type Id 1016 == INT8[] (BIGINT[]) -}; - -class DatabasePostgres extends Database { - _pgpInitOptions = { - capSQL: true, - }; - - - // eslint-disable-next-line class-methods-use-this - get schemaVersionsSupported() { - return { - min: { - major: 0, - minor: 0, - patch: 0, - }, - max: { - major: 0, - minor: 0, - patch: 0, - }, - }; - } - - - /** - * - * @param {ConsoleLike} logger - * @param {Object} options - * @param {Object} options.db - * @param {String} options.db.connectionString - * @param {String} options.db.queryLogLevel - * @param {Boolean} options.db.noWarnings set when running tests - * @param {} options.db. - * @param {*} pgpStub for tests - */ - constructor(logger, options, pgpStub) { - super(logger, options); - - // Set up - this._pgpInitOptionsConfigure(options.db); - - // istanbul ignore next - this._pgp = pgpStub || PGPromise(this._pgpInitOptions); - - // Return native BigInts - this._pgp.pg.types.setTypeParser(PGTypeId.INT8, BigInt); // Type Id 20 = INT8 (BIGINT) - const parseBigIntArray = this._pgp.pg.types.getTypeParser(PGTypeId.INT8Array); // Type Id 1016 = INT8[] (BIGINT[]) - this._pgp.pg.types.setTypeParser(PGTypeId.INT8Array, (a) => parseBigIntArray(a).map(BigInt)); - - // Establish db connection - this.db = this._pgp(options.db.connectionString); - - // Suppress QueryFile warnings when running tests - this.noWarnings = options.db.noWarnings; - } - - - async initialize(sqlPath = __dirname, applyMigrations = true) { - const _scope = _fileScope('initialize'); - this.logger.debug(_scope, 'called', { applyMigrations }); - if (applyMigrations) { - await this._initTables(sqlPath); - } - this._initStatements(sqlPath); - await super.initialize(); - } - - - async _tableExists(tableName) { - return this.db.oneOrNone('SELECT table_name FROM information_schema.tables WHERE table_name=$(tableName)', { tableName }); - } - - /** - * Create schema version tracking table and other common stuff. - */ - async _createMetaVersionTable() { - return await this.db.multiResult(`BEGIN; -CREATE EXTENSION IF NOT EXISTS pg_stat_statements; -CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; -CREATE TABLE IF NOT EXISTS _meta_schema_version ( - major BIGINT NOT NULL, - minor BIGINT NOT NULL, - patch BIGINT NOT NULL, - applied TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), - PRIMARY KEY (major, minor, patch) -); -INSERT INTO _meta_schema_version (major, minor, patch) VALUES (0, 0, 0); -COMMIT;`); - } - - - async _initTables(sqlPath = __dirname) { - const _scope = _fileScope('_initTables'); - this.logger.debug(_scope, 'called', {}); - - const _queryFile = this._queryFileHelper(); - - // Migrations rely upon this table, ensure it exists. - const metaVersionTable = '_meta_schema_version'; - - let metaExists = await this._tableExists(metaVersionTable); - if (!metaExists) { - const results = await this._createMetaVersionTable(); - this.logger.debug(_scope, 'created meta version table', { results }); - metaExists = await this._tableExists(metaVersionTable); - /* istanbul ignore if */ - if (!metaExists) { - throw new DBErrors.UnexpectedResult(`did not create ${metaVersionTable} table`); - } - this.logger.info(_scope, 'created schema version table', { metaVersionTable }); - } - - // Apply migrations - const currentSchema = await this._currentSchema(); - const migrationsWanted = unappliedSchemaVersions(sqlPath, currentSchema, this.schemaVersionsSupported); - this.logger.debug(_scope, 'schema migrations wanted', { migrationsWanted }); - for (const v of migrationsWanted) { - const fPath = path.join(sqlPath, 'sql', 'schema', v, 'apply.sql'); - const migrationSql = _queryFile(fPath); - const results = await this.db.multiResult(migrationSql); - this.logger.debug(_scope, 'executed migration sql', { version: v, results }); - this.logger.info(_scope, 'applied migration', { version: v }); - } - } - - - /** - * Configure the pgp init options object with assorted logging hooks, - * and converting returned column names from snake_case to camelCase. - * @param {Object} options - * @param {String} options.queryLogLevel - */ - _pgpInitOptionsConfigure({ queryLogLevel }) { - // Log errors - const errorScope = _fileScope('pgp:error'); - const errorLogger = this.logger.error; - - this._pgpInitOptions.error = (err, event) => { - errorLogger(errorScope, '', { err, event }); - }; - - // Log queries - let queryLogger; - if (queryLogLevel) { - const queryScope = _fileScope('pgp:query'); - queryLogger = this.logger[queryLogLevel]; // eslint-disable-line security/detect-object-injection - this._pgpInitOptions.query = (event) => { - queryLogger(queryScope, '', { - query: event?.query, - params: event?.params, - }); - }; - } - - // Deophidiate column names in-place, log results - const resultScope = _fileScope('pgp:result'); - this._pgpInitOptions.receive = ({ data, result, ctx: event }) => { - const exemplaryRow = data[0]; - for (const prop in exemplaryRow) { - const camel = Database._camelfy(prop); - if (!(camel in exemplaryRow)) { - for (const d of data) { - d[camel] = d[prop]; // eslint-disable-line security/detect-object-injection - delete d[prop]; // eslint-disable-line security/detect-object-injection - } - } - } - if (queryLogLevel) { - // Omitting .rows - queryLogger(resultScope, '', { - query: event?.query, - command: result?.command, - rowCount: result?.rowCount, - duration: result?.duration, - }); - } - }; - } - - - /** - * Wrap pgp QueryFile to impose settings and log errors. - * @returns {QueryFile} - */ - _queryFileHelper() { - return (file) => { - const _scope = _fileScope('_queryFile'); - /* istanbul ignore next */ - const qfParams = { - minify: true, - ...(this.noWarnings && { noWarnings: this.noWarnings }), - }; - const qf = new this._pgp.QueryFile(file, qfParams); - if (qf.error) { - this.logger.error(_scope, 'failed to create SQL statement', { error: qf.error, file }); - throw qf.error; - } - return qf; - }; - } - - - /** - * Load and prepare SQL from files. - */ - _initStatements(sqlPath = __dirname) { - const _scope = _fileScope('_initStatements'); - const _queryFile = this._queryFileHelper(); - this.statement = this._pgp.utils.enumSql(path.join(sqlPath, 'sql'), {}, _queryFile); - this.logger.debug(_scope, 'statements initialized', { statements: Object.keys(this.statement).length }); - } - - - async _currentSchema() { - return this.db.one('SELECT major, minor, patch FROM _meta_schema_version ORDER BY major DESC, minor DESC, patch DESC LIMIT 1'); - } - - - async context(fn) { - return this.db.task(async (t) => fn(t)); - } - - - // eslint-disable-next-line class-methods-use-this - async transaction(dbCtx, fn) { - return dbCtx.txIf(async (t) => fn(t)); - } - - - async healthCheck() { - const _scope = _fileScope('healthCheck'); - this.logger.debug(_scope, 'called', {}); - const c = await this.db.connect(); - c.done(); - return { serverVersion: c.client.serverVersion }; - } - - - async _closeConnection() { - const _scope = _fileScope('_closeConnection'); - try { - await this._pgp.end(); - } catch (e) { - this.logger.error(_scope, 'failed', { error: e }); - throw e; - } - } - - - /* istanbul ignore next */ - async _purgeTables(really = false) { - const _scope = _fileScope('_purgeTables'); - try { - if (really) { - await this.db.tx(async (t) => { - await t.batch(this._tableNames.map(async (table) => t.query('TRUNCATE TABLE $(table:name) CASCADE', { table }))); - }); - } - } catch (e) { - this.logger.error(_scope, 'failed', { error: e }); - throw e; - } - } - -} - -module.exports = DatabasePostgres; diff --git a/lib/sqlite-creator.js b/lib/sqlite-creator.js new file mode 100644 index 0000000..bd3fc44 --- /dev/null +++ b/lib/sqlite-creator.js @@ -0,0 +1,316 @@ +'use strict'; + +const SQLite = require('better-sqlite3'); +const fs = require('fs'); +const path = require('path'); +const { performance } = require('perf_hooks'); +const Database = require('./abstract'); +const DBErrors = require('./errors'); +const { unappliedSchemaVersions } = require('./schema-version-helper'); +const { fileScope } = require('@squeep/log-helper'); + +const _fileScope = fileScope(__filename); + +const SQLiteCreator = (Abstract) => { + + class DatabaseSQLite extends Abstract { + // eslint-disable-next-line class-methods-use-this + get schemaVersionsSupported() { + return { + min: { + major: 0, + minor: 0, + patch: 0, + }, + max: { + major: 0, + minor: 0, + patch: 0, + }, + }; + } + + /** + * + * @param {ConsoleLike} logger + * @param {Object} options + * @param {Object} options.db + * @param {String} options.db.connectionString + * @param {String=} options.db.queryLogLevel + * @param {BigInt=} options.db.sqliteOptimizeAfterChanges + */ + constructor(logger, options) { + super(logger, options); + + const connectionString = options.db.connectionString || 'sqlite://:memory:'; + const csDelim = '://'; + const dbFilename = connectionString.slice(connectionString.indexOf(csDelim) + csDelim.length); + + const queryLogLevel = options.db.queryLogLevel; + + const sqliteOptions = { + ...(queryLogLevel && { + // eslint-disable-next-line security/detect-object-injection + verbose: (query) => this.logger[queryLogLevel](_fileScope('SQLite:verbose'), '', { query }), + }), + }; + this.db = new SQLite(dbFilename, sqliteOptions); + this.changesSinceLastOptimize = BigInt(0); + this.optimizeAfterChanges = options.db.sqliteOptimizeAfterChanges || 0; // Default to no periodic optimization. + this.db.pragma('foreign_keys = on'); // Enforce consistency. + this.db.pragma('journal_mode = WAL'); // Be faster, expect local filesystem. + this.db.defaultSafeIntegers(true); // This probably isn't necessary, but by using these BigInts we keep weird floats out of the query logs. + } + + + initialize(sqlPath = __dirname, applyMigrations = true) { + const _scope = _fileScope('initialize'); + this.logger.debug(_scope, 'called', { applyMigrations }); + if (applyMigrations) { + this._initTables(sqlPath); + } + super.initialize(); + this._initStatements(sqlPath); + } + + + _tableExists(tableName) { + return this.db.prepare('SELECT name FROM sqlite_master WHERE type=:type AND name=:tableName') + .pluck(true) + .bind({ type: 'table', tableName }) + .get(); + } + + + /** + * Create schema version tracking table and other common stuff. + */ + _createMetaVersionTable() { + return this.db.exec(`BEGIN; +CREATE TABLE _meta_schema_version ( + major INTEGER NOT NULL CHECK (typeof(major) = 'integer'), + minor INTEGER NOT NULL CHECK (typeof(minor) = 'integer'), + patch INTEGER NOT NULL CHECK (typeof(patch) = 'integer'), + applied INTEGER NOT NULL DEFAULT (strftime('%s', 'now')) CHECK (typeof(applied) = 'integer'), + PRIMARY KEY (major DESC, minor DESC, patch DESC) +) WITHOUT ROWID; +INSERT INTO _meta_schema_version (major, minor, patch) VALUES (0, 0, 0); +COMMIT;`); + } + + + /** + * SQLite cannot prepare its statements without a schema, ensure such exists. + */ + _initTables(sqlPath = __dirname) { + const _scope = _fileScope('_initTables'); + + // Migrations rely upon this table, ensure it exists. + const metaVersionTable = '_meta_schema_version'; + let metaExists = this._tableExists(metaVersionTable); + if (metaExists === undefined) { + this._createMetaVersionTable(); + metaExists = this._tableExists(metaVersionTable); + /* istanbul ignore if */ + if (metaExists === undefined) { + throw new DBErrors.UnexpectedResult(`did not create ${metaVersionTable} table`); + } + this.logger.info(_scope, 'created schema version table', { metaVersionTable }); + } + + // Apply migrations + const currentSchema = this._currentSchema(); + const migrationsWanted = unappliedSchemaVersions(sqlPath, currentSchema, this.schemaVersionsSupported); + this.logger.debug(_scope, 'schema migrations wanted', { migrationsWanted }); + migrationsWanted.forEach((v) => { + const fPath = path.join(sqlPath, 'sql', 'schema', v, 'apply.sql'); + // eslint-disable-next-line security/detect-non-literal-fs-filename + const fSql = fs.readFileSync(fPath, { encoding: 'utf8' }); + this.logger.info(_scope, 'applying migration', { version: v }); + this.db.exec(fSql); + }); + } + + + _initStatements(sqlPath = __dirname) { + const _scope = _fileScope('_initStatements'); + const sqlDir = path.join(sqlPath, 'sql'); + this.statement = {}; + + // Decorate the statement calls we use with timing and logging. + const wrapFetch = (logName, statementName, fn) => { + const _wrapScope = _fileScope(logName); + return (...args) => { + const startTimestampMs = performance.now(); + const rows = fn(...args); + DatabaseSQLite._deOphidiate(rows); + const elapsedTimeMs = performance.now() - startTimestampMs; + this.logger.debug(_wrapScope, 'complete', { statementName, elapsedTimeMs }); + return rows; + }; + }; + const wrapRun = (logName, statementName, fn) => { + const _wrapScope = _fileScope(logName); + return (...args) => { + const startTimestampMs = performance.now(); + const result = fn(...args); + const elapsedTimeMs = performance.now() - startTimestampMs; + this._updateChanges(result); + this.logger.debug(_wrapScope, 'complete', { ...result, statementName, elapsedTimeMs }); + result.duration = elapsedTimeMs; + return result; + }; + }; + + // eslint-disable-next-line security/detect-non-literal-fs-filename + for (const f of fs.readdirSync(sqlDir)) { + const fPath = path.join(sqlDir, f); + const { name: fName, ext: fExt } = path.parse(f); + // eslint-disable-next-line security/detect-non-literal-fs-filename + const stat = fs.statSync(fPath); + if (!stat.isFile() + || fExt.toLowerCase() !== '.sql') { + continue; + } + // eslint-disable-next-line security/detect-non-literal-fs-filename + const fSql = fs.readFileSync(fPath, { encoding: 'utf8' }); + const statementName = Database._camelfy(fName.toLowerCase(), '-'); + let statement; + try { + statement = this.db.prepare(fSql); + } catch (e) /* istanbul ignore next */ { + this.logger.error(_scope, 'failed to prepare statement', { error: e, file: f }); + throw e; + } + // eslint-disable-next-line security/detect-object-injection + this.statement[statementName] = statement; + const { get: origGet, all: origAll, run: origRun } = statement; + statement.get = wrapFetch('SQLite:get', statementName, origGet.bind(statement)); + statement.all = wrapFetch('SQLite:all', statementName, origAll.bind(statement)); + statement.run = wrapRun('SQLite:run', statementName, origRun.bind(statement)); + } + this.statement._optimize = this.db.prepare('SELECT * FROM pragma_optimize(0xffff)'); + + this.logger.debug(_scope, 'statements initialized', { statements: Object.keys(this.statement).length }); + } + + + static _deOphidiate(rows) { + const rowsIsArray = Array.isArray(rows); + if (!rowsIsArray) { + rows = [rows]; + } + const exemplaryRow = rows[0]; + for (const prop in exemplaryRow) { + const camel = Database._camelfy(prop); + if (!(camel in exemplaryRow)) { + for (const d of rows) { + d[camel] = d[prop]; // eslint-disable-line security/detect-object-injection + delete d[prop]; // eslint-disable-line security/detect-object-injection + } + } + } + return rowsIsArray ? rows : rows[0]; + } + + + _currentSchema() { + return this.db.prepare('SELECT major, minor, patch FROM _meta_schema_version ORDER BY major DESC, minor DESC, patch DESC LIMIT 1').get(); + } + + + healthCheck() { + const _scope = _fileScope('healthCheck'); + this.logger.debug(_scope, 'called', {}); + if (!this.db.open) { + throw new DBErrors.UnexpectedResult('database is not open'); + } + return { open: this.db.open }; + } + + + _closeConnection() { + this.db.close(); + } + + + _optimize() { + const _scope = _fileScope('_optimize'); + + const optimize = this.statement._optimize.all(); + this.logger.debug(_scope, 'optimize', { optimize, changes: this.changesSinceLastOptimize }); + this.db.pragma('optimize'); + this.changesSinceLastOptimize = BigInt(0); + } + + + _updateChanges(dbResult) { + if (this.optimizeAfterChanges) { + this.changesSinceLastOptimize += BigInt(dbResult.changes); + if (this.changesSinceLastOptimize >= this.optimizeAfterChanges) { + this._optimize(); + } + } + } + + + _purgeTables(really) { + if (really) { + this._tableNames.forEach((table) => { + const result = this.db.prepare(`DELETE FROM ${table}`).run(); + this.logger.debug(_fileScope('_purgeTables'), 'success', { table, result }); + }); + } + } + + + context(fn) { + return fn(this.db); + } + + + transaction(dbCtx, fn) { + dbCtx = dbCtx || this.db; + return dbCtx.transaction(fn)(); + } + + + /** + * Boolean to 0/1 representation for SQLite params. + * @param {Boolean} bool + * @returns {Number} + */ + static _booleanToNumeric(bool) { + // eslint-disable-next-line security/detect-object-injection + return { + true: 1, + false: 0, + }[bool]; + } + + + /** + * Epoch seconds to Date. + * @param {Number} epoch + * @returns {Date} + */ + static _epochToDate(epoch) { + return new Date(Number(epoch) * 1000); + } + + + /** + * Date to epoch seconds. + * @param {Date} date + * @returns {Number} + */ + static _dateToEpoch(date) { + return Math.ceil(date.getTime() / 1000); + } + + } + + return DatabaseSQLite; +}; + +module.exports = SQLiteCreator; diff --git a/lib/sqlite.js b/lib/sqlite.js deleted file mode 100644 index 6ca9bf0..0000000 --- a/lib/sqlite.js +++ /dev/null @@ -1,311 +0,0 @@ -'use strict'; - -const SQLite = require('better-sqlite3'); -const fs = require('fs'); -const path = require('path'); -const { performance } = require('perf_hooks'); -const Database = require('./abstract'); -const DBErrors = require('./errors'); -const { unappliedSchemaVersions } = require('./schema-version-helper'); -const { fileScope } = require('@squeep/log-helper'); - -const _fileScope = fileScope(__filename); - -class DatabaseSQLite extends Database { - // eslint-disable-next-line class-methods-use-this - get schemaVersionsSupported() { - return { - min: { - major: 0, - minor: 0, - patch: 0, - }, - max: { - major: 0, - minor: 0, - patch: 0, - }, - }; - } - - /** - * - * @param {ConsoleLike} logger - * @param {Object} options - * @param {Object} options.db - * @param {String} options.db.connectionString - * @param {String=} options.db.queryLogLevel - * @param {BigInt=} options.db.sqliteOptimizeAfterChanges - */ - constructor(logger, options) { - super(logger, options); - - const connectionString = options.db.connectionString || 'sqlite://:memory:'; - const csDelim = '://'; - const dbFilename = connectionString.slice(connectionString.indexOf(csDelim) + csDelim.length); - - const queryLogLevel = options.db.queryLogLevel; - - const sqliteOptions = { - ...(queryLogLevel && { - // eslint-disable-next-line security/detect-object-injection - verbose: (query) => this.logger[queryLogLevel](_fileScope('SQLite:verbose'), '', { query }), - }), - }; - this.db = new SQLite(dbFilename, sqliteOptions); - this.changesSinceLastOptimize = BigInt(0); - this.optimizeAfterChanges = options.db.sqliteOptimizeAfterChanges || 0; // Default to no periodic optimization. - this.db.pragma('foreign_keys = on'); // Enforce consistency. - this.db.pragma('journal_mode = WAL'); // Be faster, expect local filesystem. - this.db.defaultSafeIntegers(true); // This probably isn't necessary, but by using these BigInts we keep weird floats out of the query logs. - } - - - initialize(sqlPath = __dirname, applyMigrations = true) { - const _scope = _fileScope('initialize'); - this.logger.debug(_scope, 'called', { applyMigrations }); - if (applyMigrations) { - this._initTables(sqlPath); - } - super.initialize(); - this._initStatements(sqlPath); - } - - - _tableExists(tableName) { - return this.db.prepare('SELECT name FROM sqlite_master WHERE type=:type AND name=:tableName') - .pluck(true) - .bind({ type: 'table', tableName }) - .get(); - } - - - /** - * Create schema version tracking table and other common stuff. - */ - _createMetaVersionTable() { - return this.db.exec(`BEGIN; -CREATE TABLE _meta_schema_version ( - major INTEGER NOT NULL CHECK (typeof(major) = 'integer'), - minor INTEGER NOT NULL CHECK (typeof(minor) = 'integer'), - patch INTEGER NOT NULL CHECK (typeof(patch) = 'integer'), - applied INTEGER NOT NULL DEFAULT (strftime('%s', 'now')) CHECK (typeof(applied) = 'integer'), - PRIMARY KEY (major DESC, minor DESC, patch DESC) -) WITHOUT ROWID; -INSERT INTO _meta_schema_version (major, minor, patch) VALUES (0, 0, 0); -COMMIT;`); - } - - - /** - * SQLite cannot prepare its statements without a schema, ensure such exists. - */ - _initTables(sqlPath = __dirname) { - const _scope = _fileScope('_initTables'); - - // Migrations rely upon this table, ensure it exists. - const metaVersionTable = '_meta_schema_version'; - let metaExists = this._tableExists(metaVersionTable); - if (metaExists === undefined) { - this._createMetaVersionTable(); - metaExists = this._tableExists(metaVersionTable); - /* istanbul ignore if */ - if (metaExists === undefined) { - throw new DBErrors.UnexpectedResult(`did not create ${metaVersionTable} table`); - } - this.logger.info(_scope, 'created schema version table', { metaVersionTable }); - } - - // Apply migrations - const currentSchema = this._currentSchema(); - const migrationsWanted = unappliedSchemaVersions(sqlPath, currentSchema, this.schemaVersionsSupported); - this.logger.debug(_scope, 'schema migrations wanted', { migrationsWanted }); - migrationsWanted.forEach((v) => { - const fPath = path.join(sqlPath, 'sql', 'schema', v, 'apply.sql'); - // eslint-disable-next-line security/detect-non-literal-fs-filename - const fSql = fs.readFileSync(fPath, { encoding: 'utf8' }); - this.logger.info(_scope, 'applying migration', { version: v }); - this.db.exec(fSql); - }); - } - - - _initStatements(sqlPath = __dirname) { - const _scope = _fileScope('_initStatements'); - const sqlDir = path.join(sqlPath, 'sql'); - this.statement = {}; - - // Decorate the statement calls we use with timing and logging. - const wrapFetch = (logName, statementName, fn) => { - const _wrapScope = _fileScope(logName); - return (...args) => { - const startTimestampMs = performance.now(); - const rows = fn(...args); - DatabaseSQLite._deOphidiate(rows); - const elapsedTimeMs = performance.now() - startTimestampMs; - this.logger.debug(_wrapScope, 'complete', { statementName, elapsedTimeMs }); - return rows; - }; - }; - const wrapRun = (logName, statementName, fn) => { - const _wrapScope = _fileScope(logName); - return (...args) => { - const startTimestampMs = performance.now(); - const result = fn(...args); - const elapsedTimeMs = performance.now() - startTimestampMs; - this._updateChanges(result); - this.logger.debug(_wrapScope, 'complete', { ...result, statementName, elapsedTimeMs }); - result.duration = elapsedTimeMs; - return result; - }; - }; - - // eslint-disable-next-line security/detect-non-literal-fs-filename - for (const f of fs.readdirSync(sqlDir)) { - const fPath = path.join(sqlDir, f); - const { name: fName, ext: fExt } = path.parse(f); - // eslint-disable-next-line security/detect-non-literal-fs-filename - const stat = fs.statSync(fPath); - if (!stat.isFile() - || fExt.toLowerCase() !== '.sql') { - continue; - } - // eslint-disable-next-line security/detect-non-literal-fs-filename - const fSql = fs.readFileSync(fPath, { encoding: 'utf8' }); - const statementName = Database._camelfy(fName.toLowerCase(), '-'); - let statement; - try { - statement = this.db.prepare(fSql); - } catch (e) /* istanbul ignore next */ { - this.logger.error(_scope, 'failed to prepare statement', { error: e, file: f }); - throw e; - } - // eslint-disable-next-line security/detect-object-injection - this.statement[statementName] = statement; - const { get: origGet, all: origAll, run: origRun } = statement; - statement.get = wrapFetch('SQLite:get', statementName, origGet.bind(statement)); - statement.all = wrapFetch('SQLite:all', statementName, origAll.bind(statement)); - statement.run = wrapRun('SQLite:run', statementName, origRun.bind(statement)); - } - this.statement._optimize = this.db.prepare('SELECT * FROM pragma_optimize(0xffff)'); - - this.logger.debug(_scope, 'statements initialized', { statements: Object.keys(this.statement).length }); - } - - - static _deOphidiate(rows) { - const rowsIsArray = Array.isArray(rows); - if (!rowsIsArray) { - rows = [rows]; - } - const exemplaryRow = rows[0]; - for (const prop in exemplaryRow) { - const camel = Database._camelfy(prop); - if (!(camel in exemplaryRow)) { - for (const d of rows) { - d[camel] = d[prop]; // eslint-disable-line security/detect-object-injection - delete d[prop]; // eslint-disable-line security/detect-object-injection - } - } - } - return rowsIsArray ? rows : rows[0]; - } - - - _currentSchema() { - return this.db.prepare('SELECT major, minor, patch FROM _meta_schema_version ORDER BY major DESC, minor DESC, patch DESC LIMIT 1').get(); - } - - - healthCheck() { - const _scope = _fileScope('healthCheck'); - this.logger.debug(_scope, 'called', {}); - if (!this.db.open) { - throw new DBErrors.UnexpectedResult('database is not open'); - } - return { open: this.db.open }; - } - - - _closeConnection() { - this.db.close(); - } - - - _optimize() { - const _scope = _fileScope('_optimize'); - - const optimize = this.statement._optimize.all(); - this.logger.debug(_scope, 'optimize', { optimize, changes: this.changesSinceLastOptimize }); - this.db.pragma('optimize'); - this.changesSinceLastOptimize = BigInt(0); - } - - - _updateChanges(dbResult) { - if (this.optimizeAfterChanges) { - this.changesSinceLastOptimize += BigInt(dbResult.changes); - if (this.changesSinceLastOptimize >= this.optimizeAfterChanges) { - this._optimize(); - } - } - } - - - _purgeTables(really) { - if (really) { - this._tableNames.forEach((table) => { - const result = this.db.prepare(`DELETE FROM ${table}`).run(); - this.logger.debug(_fileScope('_purgeTables'), 'success', { table, result }); - }); - } - } - - - context(fn) { - return fn(this.db); - } - - - transaction(dbCtx, fn) { - dbCtx = dbCtx || this.db; - return dbCtx.transaction(fn)(); - } - - - /** - * Boolean to 0/1 representation for SQLite params. - * @param {Boolean} bool - * @returns {Number} - */ - static _booleanToNumeric(bool) { - // eslint-disable-next-line security/detect-object-injection - return { - true: 1, - false: 0, - }[bool]; - } - - - /** - * Epoch seconds to Date. - * @param {Number} epoch - * @returns {Date} - */ - static _epochToDate(epoch) { - return new Date(Number(epoch) * 1000); - } - - - /** - * Date to epoch seconds. - * @param {Date} date - * @returns {Number} - */ - static _dateToEpoch(date) { - return Math.ceil(date.getTime() / 1000); - } - -} - -module.exports = DatabaseSQLite; diff --git a/test-integration/abstract.js b/test-integration/abstract.js new file mode 100644 index 0000000..c968603 --- /dev/null +++ b/test-integration/abstract.js @@ -0,0 +1,34 @@ +'use strict'; + +const Abstract = require('../lib/abstract'); + +class AbstractIntegration extends Abstract { + constructor(...args) { + super(...args); + if (!this._isProduction) { + this._abstractMethods.push(...[ + 'almanacGetAll', + 'almanacGet', + 'almanacUpsert', + ]); + this._tableNames.push(...[ + 'almanac', + ]); + } + } + + almanacGetAll(dbCtx) { + this._notImplemented('almanacGetAll', arguments); + } + + almanacGet(dbCtx, event) { + this._notImplemented('almanacGetAll', arguments); + } + + almanacUpsert(dbCtx, event, date) { + this._notImplemented('almanacGetAll', arguments); + } + +} + +module.exports = AbstractIntegration; diff --git a/test-integration/postgres/index.js b/test-integration/postgres/index.js index d0d137b..8af0c60 100644 --- a/test-integration/postgres/index.js +++ b/test-integration/postgres/index.js @@ -1,14 +1,15 @@ /* eslint-env mocha */ 'use strict'; -const DatabasePostgresBase = require('../../lib/postgres'); +const Abstract = require('../abstract'); +const PostgresCreator = require('../../lib/postgres-creator'); const DBErrors = require('../../lib/errors'); const assert = require('node:assert'); /** * A minimal but actual db class implementation. */ -class DB extends DatabasePostgresBase { +class PostgresDB extends PostgresCreator(Abstract) { // eslint-disable-next-line class-methods-use-this get schemaVersionsSupported() { @@ -85,4 +86,4 @@ class DB extends DatabasePostgresBase { } -module.exports = DB; \ No newline at end of file +module.exports = PostgresDB; diff --git a/test-integration/sqlite/index.js b/test-integration/sqlite/index.js index 40d1010..1379767 100644 --- a/test-integration/sqlite/index.js +++ b/test-integration/sqlite/index.js @@ -1,13 +1,15 @@ /* eslint-env mocha */ 'use strict'; -const DatabaseSQLiteBase = require('../../lib/sqlite'); +const assert = require('node:assert'); +const SQLiteCreator = require('../../lib/sqlite-creator'); +const Abstract = require('../abstract'); const DBErrors = require('../../lib/errors'); /** * A minimal but actual db class implementation. */ -class DB extends DatabaseSQLiteBase { +class SQLiteDB extends SQLiteCreator(Abstract) { // eslint-disable-next-line class-methods-use-this get schemaVersionsSupported() { @@ -46,7 +48,7 @@ class DB extends DatabaseSQLiteBase { this.logger.debug(_scope, 'called'); try { const entries = this.statement.almanacGetAll.all(); - return entries.map((entry) => DB._almanacEntryToNative(entry)); + return entries.map((entry) => this.constructor._almanacEntryToNative(entry)); } catch (error) { this.logger.error(_scope, 'failed', { error }); throw error; @@ -56,7 +58,7 @@ class DB extends DatabaseSQLiteBase { static _almanacEntryToNative(entry) { return { event: entry.event, - date: DatabaseSQLiteBase._epochToDate(entry.epoch), + date: super._epochToDate(entry.epoch), }; } @@ -65,7 +67,7 @@ class DB extends DatabaseSQLiteBase { this.logger.debug(_scope, 'called', { event }); try { const entry = this.statement.almanacGet.get({ event }); - const { date } = DB._almanacEntryToNative(entry); + const { date } = this.constructor._almanacEntryToNative(entry); return date; } catch (error) { this.logger.error(_scope, 'failed', { error, event }); @@ -77,7 +79,7 @@ class DB extends DatabaseSQLiteBase { const _scope = 'almanacUpsert'; this.logger.debug(_scope, 'called', { event, date }); try { - const epoch = DatabaseSQLiteBase._dateToEpoch(date); + const epoch = this.constructor._dateToEpoch(date); const result = this.statement.almanacUpsert.run({ event, epoch }); if (result.changes != 1) { throw new DBErrors.UnexpectedResult('did not update almanac'); @@ -90,4 +92,4 @@ class DB extends DatabaseSQLiteBase { } -module.exports = DB; \ No newline at end of file +module.exports = SQLiteDB; diff --git a/test/helpers.js b/test/helpers.js index b76f0b8..5e12177 100644 --- a/test/helpers.js +++ b/test/helpers.js @@ -5,7 +5,7 @@ const assert = require('node:assert'); const sinon = require('sinon'); // eslint-disable-line node/no-unpublished-require const DBErrors = require('../lib/errors'); -const nop = () => { /* */ }; +const nop = () => undefined; class StubLogger { constructor(backend) { @@ -81,4 +81,4 @@ module.exports = { StubLogger, stubPgp, itChecksImplementation, -}; \ No newline at end of file +}; diff --git a/test/integration.js b/test/integration.js index 4c86515..63e755f 100644 --- a/test/integration.js +++ b/test/integration.js @@ -129,10 +129,24 @@ describe('Database Integration', function () { step('fetch all records', async function () { const expected = events; await db.context(async (dbCtx) => { - const events = await db.almanacGetAll(dbCtx); - assert.deepStrictEqual(events, expected); + const allEvents = await db.almanacGetAll(dbCtx); + assert.deepStrictEqual(allEvents, expected); }); }); + + step('transaction', async function () { + const expected = events; + await db.context(async (dbCtx) => { + await db.transaction(dbCtx, async (txCtx) => { + for await (const event of events) { + await db.almanacUpsert(txCtx, event.event, event.date); + } + }); // txCtx + const allEvents = await db.almanacGetAll(dbCtx); + assert.deepStrictEqual(allEvents, expected); + }); + }); + }); // Almanac }); // specific implementation diff --git a/test/lib/factory.js b/test/lib/factory.js index ef690f2..e92691f 100644 --- a/test/lib/factory.js +++ b/test/lib/factory.js @@ -4,10 +4,11 @@ const assert = require('node:assert'); const sinon = require('sinon'); // eslint-disable-line node/no-unpublished-require const Factory = require('../../lib/factory'); +const Abstract = require('../../lib/abstract'); const { UnsupportedEngine } = require('../../lib/errors'); const pgp = require('pg-promise'); -const nop = () => {}; +const nop = () => unknown; describe('Factory', function () { let logger, options; @@ -24,24 +25,24 @@ describe('Factory', function () { it('covers postgres', function () { sinon.stub(pgp.utils, 'enumSql').returns({}); options.db.connectionString = 'postgresql://example'; - const factory = new Factory(logger, options); + const factory = new Factory(Abstract, logger, options); assert(factory); }); it('covers sqlite', function () { options.db.connectionString = 'sqlite://:memory:'; - const factory = new Factory(logger, options); + const factory = new Factory(Abstract, logger, options); assert(factory); }); it('covers unknown', function () { options.db.connectionString = 'redis://example'; - assert.throws(() => new Factory(logger, options), UnsupportedEngine); + assert.throws(() => new Factory(Abstract, logger, options), UnsupportedEngine); }); - it('covers empty', function () { delete options.db.connectionString; - assert.throws(() => new Factory(logger, options), UnsupportedEngine); + assert.throws(() => new Factory(Abstract, logger, options), UnsupportedEngine); }); + }); // Factory diff --git a/test/lib/postgres.js b/test/lib/postgres-creator.js similarity index 86% rename from test/lib/postgres.js rename to test/lib/postgres-creator.js index 65f1b94..916ef38 100644 --- a/test/lib/postgres.js +++ b/test/lib/postgres-creator.js @@ -4,13 +4,15 @@ const assert = require('node:assert'); const sinon = require('sinon'); // eslint-disable-line node/no-unpublished-require const fs = require('node:fs'); -const DatabasePostgres = require('../../lib/postgres'); +const Abstract = require('../../lib/abstract'); +const PostgresCreator = require('../../lib/postgres-creator'); const DBErrors = require('../../lib/errors'); const { StubLogger, stubPgp, nop, itChecksImplementation } = require('../helpers'); -describe('DatabasePostgres', function () { +describe('Postgres Creator', function () { let db, options, stubLogger; const expectedException = new Error('oh no'); + const DatabasePostgres = PostgresCreator(Abstract); beforeEach(function () { options = { @@ -119,6 +121,36 @@ describe('DatabasePostgres', function () { }); // receive }); // pgpInitOptions + describe('_tableExists', function () { + beforeEach(function () { + sinon.stub(db.db, 'oneOrNone'); + }); + it('covers no pgSchema', async function () { + await db._tableExists('almanac', db.pgSchema); + assert(db.db.oneOrNone.called); + }); + it('covers pgSchema', async function () { + db.pgSchema = 'some_schema'; + await db._tableExists('almanac', db.pgSchema); + assert(db.db.oneOrNone.called); + }); + }); // _tableExists + + describe('_currentSchema', function () { + beforeEach(function () { + sinon.stub(db.db, 'one'); + }); + it('covers no pgSchema', async function () { + await db._currentSchema(); + assert(db.db.one.called); + }); + it('covers pgSchema', async function () { + db.pgSchema = 'some_schema'; + await db._currentSchema(); + assert(db.db.one.called); + }); + }); // _currentSchema + describe('_initTables', function () { beforeEach(function () { sinon.stub(db.db, 'multiResult').resolves(); @@ -138,6 +170,14 @@ describe('DatabasePostgres', function () { .onCall(1).resolves({ 'table_name': '_meta_schema_version' }); await db._initTables(); }); + it('covers apply with pgSchema', async function () { + db.pgSchema = 'some_schema'; + db._currentSchema.resolves({ major: 0, minor: 0, patch: 0 }); + db._tableExists + .onCall(0).resolves() + .onCall(1).resolves({ 'table_name': '_meta_schema_version' }); + await db._initTables(); + }); it('covers exists', async function () { db._tableExists.resolves({ 'table_name': '_meta_schema_version' }); db._currentSchema.resolves(db.schemaVersionsSupported.max); diff --git a/test/lib/sqlite.js b/test/lib/sqlite-creator.js similarity index 98% rename from test/lib/sqlite.js rename to test/lib/sqlite-creator.js index e6d1af9..9902679 100644 --- a/test/lib/sqlite.js +++ b/test/lib/sqlite-creator.js @@ -4,13 +4,15 @@ const assert = require('node:assert'); const sinon = require('sinon'); // eslint-disable-line node/no-unpublished-require const fs = require('node:fs'); -const DatabaseSQLite = require('../../lib/sqlite'); +const Abstract = require('../../lib/abstract'); +const SQLiteCreator = require('../../lib/sqlite-creator'); const DBErrors = require('../../lib/errors'); const { StubLogger, stubPgp, nop, itChecksImplementation } = require('../helpers'); describe('DatabaseSQLite', function () { let db, options, stubLogger; const expectedException = new Error('oh no'); + const DatabaseSQLite = SQLiteCreator(Abstract); beforeEach(function () { options = {