engine classes now generated from abstract, rearranged some support
authorJustin Wind <justin.wind+git@gmail.com>
Wed, 31 Jan 2024 19:40:59 +0000 (11:40 -0800)
committerJustin Wind <justin.wind+git@gmail.com>
Wed, 31 Jan 2024 19:40:59 +0000 (11:40 -0800)
README.md
index.js
lib/abstract.js
test-integration/abstract.js
test/helpers.js
test/lib/abstract.js
test/lib/postgres-creator.js
test/lib/sqlite-creator.js
test/lib/validation.js
test/stub.js [new file with mode: 0644]

index fcbcc9b7be2ce6a76d77a0acd38e6b4f450e32b1..61467f283cf94304bd2e4ce78d1f966f8af49609 100644 (file)
--- 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.
 
index 500890ced5e9da0f545b981077d8180dabac0bcc..c22d0a97f46d1d20d20c3318d876e2b5ac36383f 100644 (file)
--- 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,
+  },
 };
index 6e98137e857cd49d2ae84b78f1f9e0a9a2cb7f64..d6df3e26221a9e45b83e0c625f06ae46229dafb0 100644 (file)
@@ -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 = [];
     }
index 2f38580d0fcb56fcc4cf4ba35cf4285632e604bf..26b110fdf696e0de164fdae3da521fe9c8acd637 100644 (file)
@@ -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',
       ]);
index 5e12177abf1df5ec5cb3bd9f28bac25c5d84a21b..2c61041e9da21520877a40c5163f70a1a07f5545 100644 (file)
@@ -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) {
index a0011484699fc03f6e7f6754c1532658123664c0..891e5dfd9021365a85b43788e397de489d81dee7 100644 (file)
@@ -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
index 916ef38ca5cb456f0a86bf06bc925f28377af66a..ab84ec1f6796c56fd3afab448be0cd28507a5463 100644 (file)
@@ -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 () {
index 99026798bbfcfe1bb96bb877247e6a7762c9397c..5390762bc1cff9ecb897007a262ae51736d29166 100644 (file)
@@ -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 () {
index 6291af391e8c1d0161f8b647fadd56cbe1fc98b4..cb6be7c50b48f90aa9e204ccb5efa7f6672eb45e 100644 (file)
@@ -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 (file)
index 0000000..2444293
--- /dev/null
@@ -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,
+};