From 116912c6816fd389575b6320272a42bc905ed15d Mon Sep 17 00:00:00 2001 From: Justin Wind Date: Wed, 31 Jan 2024 11:40:59 -0800 Subject: [PATCH] engine classes now generated from abstract, rearranged some support --- README.md | 6 +- index.js | 7 ++ lib/abstract.js | 13 --- test-integration/abstract.js | 5 -- test/helpers.js | 9 ++- test/lib/abstract.js | 55 ++++++------- test/lib/postgres-creator.js | 11 ++- test/lib/sqlite-creator.js | 19 +++-- test/lib/validation.js | 2 - test/stub.js | 152 +++++++++++++++++++++++++++++++++++ 10 files changed, 215 insertions(+), 64 deletions(-) create mode 100644 test/stub.js diff --git a/README.md b/README.md index fcbcc9b..61467f2 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,9 @@ Helper classes, utilities, and opinions for supporting multiple database engines See files in `test-integration` for example implementation. -Your database interface is defined by extending the Abstract class. +The Abstract class provides boilerplate initialization, and defines some abstract methods which the engines implement. +Your database interface is defined by extending this Abstract class with your abstract interface methods.. -Your database engines are implemented by extending a class created by passing your abstract interface class to an engine class creator function. +Your abstract interface class is then provided to a function for an engine, which creates an abstract engine class that includes engine-specific boilerplate and implements some of the base abstract methods. +Your engine implementations extend this abstract engine class. diff --git a/index.js b/index.js index 500890c..c22d0a9 100644 --- a/index.js +++ b/index.js @@ -7,6 +7,8 @@ const SchemaVersionHelper = require('./lib/schema-version-helper'); const PostgresCreator = require('./lib/postgres-creator'); const SQLiteCreator = require('./lib/sqlite-creator'); const { validate } = require('./lib/validation'); +const { stubCreator, stubPgp } = require('./test/stub'); +const { itChecksImplementation } = require('./test/helpers'); module.exports = { Abstract, @@ -16,4 +18,9 @@ module.exports = { SQLiteCreator, PostgresCreator, validate, + test: { + itChecksImplementation, + stubCreator, + stubPgp, + }, }; diff --git a/lib/abstract.js b/lib/abstract.js index 6e98137..d6df3e2 100644 --- a/lib/abstract.js +++ b/lib/abstract.js @@ -26,19 +26,6 @@ class Database { if (!this._isProduction) { // Configure metadata used by tests and extra validation when not in production. - // List of generic methods an engine needs to implement to reify this model. - this._abstractMethods = [ - 'schemaVersionsSupported', - '_currentSchema', - '_tableExists', - 'healthCheck', - 'context', - 'transaction', - ]; - - // List of methods specific to database interface. - this._interfaceMethods = []; - // List of tables for tests to purge during integration tests. this._tableNames = []; } diff --git a/test-integration/abstract.js b/test-integration/abstract.js index 2f38580..26b110f 100644 --- a/test-integration/abstract.js +++ b/test-integration/abstract.js @@ -6,11 +6,6 @@ class AbstractIntegration extends Abstract { constructor(...args) { super(...args); if (!this._isProduction) { - this._abstractMethods.push(...[ - 'almanacGetAll', - 'almanacGet', - 'almanacUpsert', - ]); this._tableNames.push(...[ 'almanac', ]); diff --git a/test/helpers.js b/test/helpers.js index 5e12177..2c61041 100644 --- a/test/helpers.js +++ b/test/helpers.js @@ -60,8 +60,13 @@ stubPgp.utils = { stubPgp.end = nop; -async function itChecksImplementation(db) { - const results = await Promise.allSettled(db._abstractMethods.map(async (fn) => { +/** + * Mocha function to validate all interface methods are implemented. + * @param {*} db + * @param {String[]} interfaceMethods + */ +async function itChecksImplementation(db, interfaceMethods) { + const results = await Promise.allSettled(interfaceMethods.map(async (fn) => { try { await db[fn](db.db); // eslint-disable-line security/detect-object-injection } catch (e) { diff --git a/test/lib/abstract.js b/test/lib/abstract.js index a001148..891e5df 100644 --- a/test/lib/abstract.js +++ b/test/lib/abstract.js @@ -6,7 +6,7 @@ const assert = require('node:assert'); const sinon = require('sinon'); // eslint-disable-line node/no-unpublished-require const { StubLogger } = require('../helpers'); const Abstract = require('../../lib/abstract'); -const { DataValidation, MigrationNeeded, NotImplemented } = require('../../lib/errors'); +const { MigrationNeeded, NotImplemented } = require('../../lib/errors'); describe('Abstract', function () { let db, stubLogger, options; @@ -83,40 +83,31 @@ describe('Abstract', function () { }) }); // _notImplemented - describe('schemaVersionsSupported', function () { - it('needs implementation', function () { - assert.throws(() => db.schemaVersionsSupported, NotImplemented); + describe('abstract methods', function () { + const abstractMethods = [ + '_currentSchema', + '_tableExists', + 'healthCheck', + 'context', + 'transaction', + ]; + it('need implementation', async function () { + for await (const m of abstractMethods) { + await assert.rejects(() => db[m](), NotImplemented); + } }); - }); // schemaVersionsSupported + }); // abstract methods - describe('_tableExists', function () { - it('needs implementation', async function () { - assert.rejects(() => db._tableExists(), NotImplemented); - }); - }); // _currentSchema - - describe('_currentSchema', function () { - it('needs implementation', async function () { - assert.rejects(() => db._currentSchema(), NotImplemented); - }); - }); // _currentSchema - - describe('healthCheck', function () { - it('needs implementation', async function () { - assert.rejects(() => db.healthCheck(), NotImplemented); - }); - }); // healthCheck - - describe('context', function () { - it('needs implementation', async function () { - assert.rejects(() => db.context(), NotImplemented); - }); - }); // context + describe('abstract getters', function () { + const abstractGetters = [ + 'schemaVersionsSupported', + ]; - describe('transaction', function () { - it('needs implementation', async function () { - assert.rejects(() => db.transaction(), NotImplemented); + it('need implementation', function () { + for (const m of abstractGetters) { + assert.throws(() => db[m], NotImplemented); + } }); - }); // transaction + }); // abstract getters }); // Abstract diff --git a/test/lib/postgres-creator.js b/test/lib/postgres-creator.js index 916ef38..ab84ec1 100644 --- a/test/lib/postgres-creator.js +++ b/test/lib/postgres-creator.js @@ -5,9 +5,10 @@ const assert = require('node:assert'); const sinon = require('sinon'); // eslint-disable-line node/no-unpublished-require const fs = require('node:fs'); const Abstract = require('../../lib/abstract'); +const { interfaceMethods, stubPgp } = require('../stub'); const PostgresCreator = require('../../lib/postgres-creator'); const DBErrors = require('../../lib/errors'); -const { StubLogger, stubPgp, nop, itChecksImplementation } = require('../helpers'); +const { StubLogger, nop, itChecksImplementation } = require('../helpers'); describe('Postgres Creator', function () { let db, options, stubLogger; @@ -37,8 +38,14 @@ describe('Postgres Creator', function () { assert(db); }); + /** + * No interface methods here, but provided as example for derivative tests. + */ describe('Implementation', function () { - it('implements engine interface', () => itChecksImplementation(db)); + it('implements engine interface', function () { + const methods = interfaceMethods(Abstract); + itChecksImplementation(db, methods); + }); }); // Implementation describe('_pgpInitOptions', function () { diff --git a/test/lib/sqlite-creator.js b/test/lib/sqlite-creator.js index 9902679..5390762 100644 --- a/test/lib/sqlite-creator.js +++ b/test/lib/sqlite-creator.js @@ -5,9 +5,10 @@ const assert = require('node:assert'); const sinon = require('sinon'); // eslint-disable-line node/no-unpublished-require const fs = require('node:fs'); const Abstract = require('../../lib/abstract'); +const { interfaceMethods } = require('../stub'); const SQLiteCreator = require('../../lib/sqlite-creator'); const DBErrors = require('../../lib/errors'); -const { StubLogger, stubPgp, nop, itChecksImplementation } = require('../helpers'); +const { StubLogger, nop, itChecksImplementation } = require('../helpers'); describe('DatabaseSQLite', function () { let db, options, stubLogger; @@ -23,7 +24,7 @@ describe('DatabaseSQLite', function () { }; stubLogger = new StubLogger(); stubLogger._spy(); - db = new DatabaseSQLite(stubLogger, options, stubPgp); + db = new DatabaseSQLite(stubLogger, options); sinon.stub(fs, 'readdirSync').returns([]); sinon.stub(fs, 'statSync').returns({ isDirectory: () => true, @@ -38,22 +39,28 @@ describe('DatabaseSQLite', function () { it('covers default connection string', function () { delete options.db.connectionString; - db = new DatabaseSQLite(stubLogger, options, stubPgp); + db = new DatabaseSQLite(stubLogger, options); }); it('covers no query logging', function () { delete options.db.queryLogLevel; - db = new DatabaseSQLite(stubLogger, options, stubPgp); + db = new DatabaseSQLite(stubLogger, options); assert(db); }); it('covers initialize without migration', function () { - db = new DatabaseSQLite(stubLogger, options, stubPgp); + db = new DatabaseSQLite(stubLogger, options); db.initialize(undefined, false); }); + /** + * No interface methods here, but provided as example for derivative tests. + */ describe('Implementation', function () { - it('implements engine interface', () => itChecksImplementation(db)); + it('implements engine interface', function () { + const methods = interfaceMethods(Abstract); + itChecksImplementation(db, methods); + }); }); // Implementation describe('_initTables', function () { diff --git a/test/lib/validation.js b/test/lib/validation.js index 6291af3..cb6be7c 100644 --- a/test/lib/validation.js +++ b/test/lib/validation.js @@ -3,8 +3,6 @@ 'use strict'; const assert = require('node:assert'); -const sinon = require('sinon'); // eslint-disable-line node/no-unpublished-require -const { StubLogger } = require('../helpers'); const Validation = require('../../lib/validation'); const { DataValidation } = require('../../lib/errors'); diff --git a/test/stub.js b/test/stub.js new file mode 100644 index 0000000..2444293 --- /dev/null +++ b/test/stub.js @@ -0,0 +1,152 @@ +'use strict'; + +const sinon = require('sinon'); + +const nop = () => undefined; + +class QueryFileStub { + error = undefined; + constructor(file, options) { + this.file = file; + this.options = options; + } +} + +/** + * Creates an object to supply to derived postgres implementations for testing. + * @returns {Object} + */ +const stubPgp = () => { + const stub = { + result: () => ({ rows: [] }), + all: nop, + get: nop, + run: nop, + one: nop, + manyOrNone: nop, + oneOrNone: nop, + query: nop, + batch: nop, + multiResult: nop, + connect: nop, + }; + stub.tx = (fn) => fn(stub); + stub.txIf = (fn) => fn(stub); + stub.task = (fn) => fn(stub); + return stub; +}; +stubPgp.pg = { + types: { + getTypeParser: nop, + setTypeParser: nop, + }, +}; +stubPgp.QueryFile = QueryFileStub; +stubPgp.utils = { + enumSql: () => ({}), +}; +stubPgp.end = nop; + + +/** + * Default lists of extant methods on abstract class. + */ +const stubMetadata = () => { + return { + // List of generic getters an engine needs to implement. + // These throw NotImplemented from Abstract. + abstractGetters: [ + 'schemaVersionsSupported', + ], + + // List of generic methods an engine needs to implement to reify this model. + // These throw NotImplemented from Abstract. + abstractMethods: [ + '_currentSchema', + '_tableExists', + 'healthCheck', + 'context', + 'transaction', + ], + + // These methods get spies instead of stubs. + spyMethods: [ + 'context', + 'transaction', + ], + + // Populated on stub class. + // These should all throw NotImplemented from Abstract. + interfaceMethods: [], + }; +}; + + +/** + * Create a class with hooks to stub the interface methods of the provided abstract class. + * @param {*} Abstract + * @returns {*} + */ +function stubCreator(Abstract, metadata = stubMetadata()) { + // Juggle some stuff to stub an interface class. + class DatabaseStub extends Abstract { + _stubMetadata = metadata; + + constructor(...args) { + super(...args); + this._stubMetadata.interfaceMethods = interfaceMethods(Abstract, metadata); + this._spyMethods.forEach((m) => sinon.spy(this, m)); + this._sinonReset(); + } + + async context(fn) { + await fn({}); + } + + async transaction(dbCtx, fn) { + await fn(dbCtx); + } + + _sinonReset() { + this._stubMetadata.abstractGetters + .forEach((m) => sinon.stub(this, m).get()) + ; + this._stubMetadata.abstractMethods + .filter((m) => !this._stubMetadata.spyMethods.includes(m)) + .forEach((m) => sinon.stub(this, m)) + ; + this._stubMetadata.interfaceMethods + .forEach((m) => sinon.stub(this, m)) + ; + this._stubMetadata.spyMethods + .forEach((m) => this[m].resetHistory()) + ; + } + } + return DatabaseStub; +} + + +/** + * Isolate list of interface methods from abstract class. + * @param {*} AbstractClass + * @param {*} metadata + * @returns {String[]} + */ +function interfaceMethods(AbstractClass, metadata = stubMetadata()) { + const proto = AbstractClass.prototype; + return Object.getOwnPropertyNames(proto) + .filter((m) => typeof Object.getOwnPropertyDescriptor(proto, m).value === 'function' + && m !== 'constructor' + && !metadata.abstractGetters.includes(m) + && !metadata.abstractMethods.includes(m) + ); +} + + +module.exports = { + interfaceMethods, + stubMetadata, + stubCreator, + stubPgp, +}; -- 2.49.0