From adaa3d00978fe67a1ca9c993b3a3f692af3da0b7 Mon Sep 17 00:00:00 2001 From: Abraham Ferguson Date: Mon, 11 Sep 2017 14:59:12 -0700 Subject: [PATCH] constructed rethinkdb data service module --- .babelrc | 24 ++++ .eslintignore | 1 + .eslintrc | 24 +++- README.md | 77 +++++++++- eslint-plugin-import-use/flag-import.js | 13 ++ eslint-plugin-import-use/package.json | 11 ++ package.json | 34 ++++- src/index.js | 4 +- .../dataService/__tests__/dataService.test.js | 129 +++++++++++++++++ .../__tests__/models/dummyModel.js | 20 +++ .../__tests__/models/dummyModelTwo.js | 26 ++++ .../__tests__/queries/dummyQuery.js | 2 + .../__tests__/queries/dummyQueryTwo.js | 2 + src/services/dataService/index.js | 131 ++++++++++++++++++ src/services/index.js | 0 src/test/config.js | 34 +++++ src/test/mocha.opts | 1 + src/util/index.js | 30 ++++ 18 files changed, 552 insertions(+), 11 deletions(-) create mode 100644 .babelrc create mode 100644 eslint-plugin-import-use/flag-import.js create mode 100644 eslint-plugin-import-use/package.json create mode 100644 src/services/dataService/__tests__/dataService.test.js create mode 100644 src/services/dataService/__tests__/models/dummyModel.js create mode 100644 src/services/dataService/__tests__/models/dummyModelTwo.js create mode 100644 src/services/dataService/__tests__/queries/dummyQuery.js create mode 100644 src/services/dataService/__tests__/queries/dummyQueryTwo.js create mode 100644 src/services/dataService/index.js delete mode 100644 src/services/index.js create mode 100644 src/test/config.js create mode 100644 src/test/mocha.opts create mode 100644 src/util/index.js diff --git a/.babelrc b/.babelrc new file mode 100644 index 0000000..6b6a7ec --- /dev/null +++ b/.babelrc @@ -0,0 +1,24 @@ +{ + "presets": ["es2015", "stage-2"], + "plugins": [ + ["transform-builtin-extend", {"globals": ["Error"]}], + ["transform-object-rest-spread"] + ], + "env": { + "test": { + "sourceMap": "inline" + }, + "development": { + "ignore": [ + "test", + "__test__" + ] + }, + "production": { + "ignore": [ + "test", + "__test__" + ] + } + } +} diff --git a/.eslintignore b/.eslintignore index dc3acac..0613858 100644 --- a/.eslintignore +++ b/.eslintignore @@ -1,3 +1,4 @@ coverage dist **/node_modules/* +lib diff --git a/.eslintrc b/.eslintrc index 50462bb..7c8204a 100644 --- a/.eslintrc +++ b/.eslintrc @@ -1,13 +1,27 @@ { + "ecmaFeatures": { + "modules": false + }, + "env": { + "browser": true, + "node": true, + "mocha": true + }, "extends": "airbnb-base", + "globals": { + "expect": true + }, + "parserOptions": { + "sourceyType": "script" + }, + "plugins": ["import-use"], "rules": { "semi": ["error", "never"], "object-curly-spacing": ["error", "never"], "no-use-before-define": ["error", {"functions": false, "classes": true}], - "no-underscore-dangle": 0 - }, - "env": { - "browser": true, - "node": true + "no-param-reassign": [2, {"props": false}], + "no-underscore-dangle": 0, + "import-use/flag-import": 2, + "object-shorthand": ["error", "properties"] } } diff --git a/README.md b/README.md index f8457d5..0784c1d 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,78 @@ -# lg-toolbox +# Lg-Toolbox -Shared Learners Guild utilities. +This is the Lg-Toolbox, a set of tools that can be used across multiple services of the LOS. -## LICENSE +## Install + + ``` + npm install lg-toolbox --save + + yarn add lg-toolbox + ``` + +## Usage + +- In Ecmascript 6: + ```javascript + import {dataService} from 'lg-toolbox' + ``` +- In Ecmascript 5 or lower: + ```javascript + const toolbox = require('lg-toolbox') + toolbox.dataService(rethinkdb, options) + ``` + If rethinkdbdash is already currently instantiated: + ```javascript + const rethinkDashInstance = rethinkdbdash() + + const ds = dataService(rethinkDashInstance) +``` +- If config object is provided, dataService will return a rethinkdbdash instance with the given config: +```javascript + const config = { + host: hostname, + port: port, + db: pathname, + authKey: auth, + ssl: dbCert, + relativeTo: DATA_SERVICE_DIR, + migrationsDirectory: directory name, + } + + const ds = dataService(config) +``` +- When adding optional Thinky Models and/or Queries: +```javascript + const options = { + models: [array-of-models] || relative directoryPath, + queries: [array-of-queries] || relative directoryPath + } + + const ds = dataService(config, options) + + ds will contain: + r: { rethinkdbdash instance }, + models: [ array of thinky models ], + queries: [ array of query functions ] + + const { r, models, queries } = ds + + ``` +## Includes + + - dataService + +## Third Party modules + + - auto-loader + - rethinkdb + - rethinkdbdash + - thinky + +## Contributing + + Create Github issues for all bugs, features & requests. Pull requests are welcome. Make sure tests are included. + +## License See the [LICENSE](./LICENSE) file. diff --git a/eslint-plugin-import-use/flag-import.js b/eslint-plugin-import-use/flag-import.js new file mode 100644 index 0000000..2fd1848 --- /dev/null +++ b/eslint-plugin-import-use/flag-import.js @@ -0,0 +1,13 @@ +module.exports = { + rules: { + 'flag-import': { + create: function (context) { + return { + ImportDeclaration(node) { + context.report(node, 'Cannot use Import in CommonJs Module') + }, + } + }, + }, + }, +} diff --git a/eslint-plugin-import-use/package.json b/eslint-plugin-import-use/package.json new file mode 100644 index 0000000..a15ea42 --- /dev/null +++ b/eslint-plugin-import-use/package.json @@ -0,0 +1,11 @@ +{ + "name": "eslint-plugin-import-use", + "version": "0.0.1", + "main": "flag-import.js", + "devDependencies": { + "eslint": "~4.4.1" + }, + "engines": { + "node": ">=0.10.0" + } +} diff --git a/package.json b/package.json index d378717..1b59452 100644 --- a/package.json +++ b/package.json @@ -2,23 +2,53 @@ "name": "lg-toolbox", "version": "1.0.0", "description": "Shared Learners Guild utilities", + "lib": "main", "engines": { "node": "6.11.x", "npm": "3.7.x" }, "scripts": { - "test": "./node_modules/.bin/eslint src/**/*.js" + "build": "NODE_ENV=production npm run clean && ./node_modules/.bin/babel -d lib/ src/", + "clean": "./node_modules/.bin/rimraf ./lib/*", + "prepublish": "npm run build && npm test", + "test:run": "NODE_ENV=test ./node_modules/.bin/mocha --opts ./src/test/mocha.opts $(find . -path './**/__tests__/*.test.js' ! -ipath '*node_modules*')", + "test:lint": "./node_modules/.bin/eslint .", + "test": "npm run build && npm run test:lint && npm run test:run" }, "authors": [ { "name": "Learners Guild", "email": "sj@learnersguild.org" + }, + { + "name": "Abraham Ferguson", + "email": "jhnfgie1989@gmail.com" + }, + { + "name": "Jose Chavez", + "email": "jbchavez19@gmail.com" } ], "license": "MIT", "devDependencies": { + "chai": "^4.1.1", + "chai-as-promised": "^7.1.1", "eslint": "^4.4.1", "eslint-config-airbnb-base": "^11.3.1", - "eslint-plugin-import": "^2.7.0" + "eslint-plugin-import-use": "file:eslint-plugin-import-use", + "jsdom": "^8.0.4", + "mocha": "^3.5.0", + "sinon-chai": "^2.13.0" + }, + "dependencies": { + "auto-loader": "^0.2.0", + "babel-cli": "^6.24.1", + "babel-core": "^6.25.0", + "babel-polyfill": "^6.26.0", + "babel-preset-es2015": "^6.24.1", + "babel-preset-stage-2": "^6.24.1", + "rethinkdb": "^2.3.3", + "rethinkdbdash": "^2.3.31", + "thinky": "^2.3.8" } } diff --git a/src/index.js b/src/index.js index c398d46..522aea7 100644 --- a/src/index.js +++ b/src/index.js @@ -1 +1,3 @@ -export {default} from './services' +const dataService = require('./services/dataService') + +module.exports = dataService diff --git a/src/services/dataService/__tests__/dataService.test.js b/src/services/dataService/__tests__/dataService.test.js new file mode 100644 index 0000000..44798ec --- /dev/null +++ b/src/services/dataService/__tests__/dataService.test.js @@ -0,0 +1,129 @@ +const path = require('path') + +const rethinkdbdash = require('rethinkdbdash') + +const dataService = require('../index') +const truncateTables = require('../../../util/index').truncateTables +const dummyModelModel = require('./models/dummyModel') +const dummyModelModelTwo = require('./models/dummyModelTwo') +const dummyQuery = require('./queries/dummyQuery') +const dummyQueryTwo = require('./queries/dummyQueryTwo') + +const dbName = 'lgToolboxTest' +const rethinkdb = rethinkdbdash({db: dbName}) + +describe('data service', () => { + describe('rethinkdb param', () => { + describe('is a config object', () => { + it('returns a rethinkdb instance with provided config', () => { + const ds = dataService({}) + expect(ds.r).to.be.a('function') + }) + }) + describe('it is a rethinkdb client', () => { + it('returns the same rethinkdb instance', () => { + const ds = dataService(rethinkdb) + expect(ds.r).to.be.a('function') + expect(ds.r).to.be.eql(rethinkdb) + }) + }) + it('throws an error if null', () => { + expect(dataService).to.throw('rethinkdb parameter config or client is required') + }) + it('throws an error if not a config object or a rethinkdb client', () => { + expect(() => dataService('I\'m a random string')).to.throw('first parameter must be a config object or client') + }) + }) + + describe('options param', () => { + describe('models', () => { + it('throws an error if not file path or array', () => { + expect(() => dataService(rethinkdb, {models: 5})).to.throw('options.models must be a path to directory or array of model definition functions') + }) + describe('is a file path', () => { + it('returns thinky models', () => { + const options = {models: path.join(__dirname, '/models')} + const ds = dataService(rethinkdb, options) + expect(ds).to.have.own.property('DummyModel') + expect(ds).to.have.own.property('DummyModelTwo') + }) + }) + describe('is an array', () => { + it('returns thinky models', () => { + const options = {models: [dummyModelModel, dummyModelModelTwo]} + const ds = dataService(rethinkdb, options) + expect(ds).to.have.own.property('DummyModel') + expect(ds).to.have.own.property('DummyModelTwo') + }) + }) + }) + describe('queries', () => { + it('throws an error if not file path or array', () => { + expect(() => dataService(rethinkdb, {queries: 5})).to.throw('options.queries must be a path to directory or array of query functions') + }) + describe('is a file path', () => { + it('returns query functions contained within the data service object', () => { + const options = {queries: path.join(__dirname, '/queries')} + const ds = dataService(rethinkdb, options) + expect(ds).to.have.own.property('dummyQuery') + expect(ds).to.have.own.property('dummyQueryTwo') + }) + }) + describe('is an array', () => { + it('returns query functions contained within the data service object', () => { + const options = {queries: [dummyQuery, dummyQueryTwo]} + const ds = dataService(rethinkdb, options) + expect(ds).to.have.own.property('dummyQuery') + expect(ds).to.have.own.property('dummyQueryTwo') + }) + }) + }) + }) + describe('thinky models', async () => { + const options = {models: [dummyModelModel, dummyModelModelTwo]} + const ds = dataService(rethinkdb, options) + const {DummyModel} = ds + + let dummy + beforeEach(async () => { + await truncateTables(rethinkdb) + dummy = await DummyModel.save({}) + }) + it('save method sets updatedAt property', async () => { + expect(dummy).to.be.an('object') + expect(dummy).to.have.own.property('id') + expect(dummy).to.have.own.property('updatedAt') + expect(dummy.id).to.be.a('string') + expect(dummy.updatedAt).to.be.an.instanceof(Date) + }) + it('updateWithTimestamp sets updatedAt property to current time', async () => { + const updatedDummy = await DummyModel + .get(dummy.id) + .updateWithTimestamp() + expect(dummy.id).to.be.equal(updatedDummy.id) + expect(dummy.updatedAt).to.be.an.instanceof(Date) + expect(updatedDummy.updatedAt).to.be.an.instanceof(Date) + expect(dummy.updatedAt).to.not.be.equal(updatedDummy.updatedAt) + }) + it('upsert either saves or updates rows based on whether instance exists', async () => { + const savedDummy = await DummyModel.upsert() + expect(savedDummy).to.be.an('object') + expect(savedDummy).to.have.own.property('id') + expect(savedDummy).to.have.own.property('updatedAt') + const updatedDummy = await DummyModel.upsert(dummy) + expect(dummy.id).to.be.equal(updatedDummy.id) + expect(dummy.updatedAt).to.not.be.equal(updatedDummy.updatedAt) + }) + it('creates thinky model associations', async () => { + const {DummyModelTwo} = ds + const dummyTwo = await DummyModelTwo.save({ + dummyModelId: dummy.id, + name: 'My Name is DummyTwo and I belong to Dummy', + }) + const dummyTwoJoined = await DummyModelTwo.get(dummyTwo.id) + .getJoin() + .run() + expect(dummyTwoJoined).has.own.property('dummys') + }) + }) +}) diff --git a/src/services/dataService/__tests__/models/dummyModel.js b/src/services/dataService/__tests__/models/dummyModel.js new file mode 100644 index 0000000..abe3077 --- /dev/null +++ b/src/services/dataService/__tests__/models/dummyModel.js @@ -0,0 +1,20 @@ +function dummyModelModel(thinky) { + const {r, type: {string, date}} = thinky + return { + name: 'DummyModel', + table: 'dummys', + schema: { + id: string() + .uuid(4) + .allowNull(false), + updatedAt: date() + .allowNull(false) + .default(r.now()), + }, + associate: (DummyModel, models) => { + DummyModel.hasOne(models.DummyModelTwo, 'twoDummys', 'id', 'dummyModelId', {init: false}) + }, + } +} + +module.exports = dummyModelModel diff --git a/src/services/dataService/__tests__/models/dummyModelTwo.js b/src/services/dataService/__tests__/models/dummyModelTwo.js new file mode 100644 index 0000000..3249811 --- /dev/null +++ b/src/services/dataService/__tests__/models/dummyModelTwo.js @@ -0,0 +1,26 @@ +function dummyModelModelTwo(thinky) { + const {r, type: {string, date}} = thinky + return { + name: 'DummyModelTwo', + table: 'twoDummys', + schema: { + id: string() + .uuid(4) + .allowNull(false), + updatedAt: date() + .allowNull(false) + .default(r.now()), + dummyModelId: string() + .uuid(4) + .allowNull(false), + name: string() + .allowNull(false) + .default('dummyModel'), + }, + associate: (DummyModelTwo, models) => { + DummyModelTwo.belongsTo(models.DummyModel, 'dummys', 'dummyModelId', 'id', {init: false}) + }, + } +} + +module.exports = dummyModelModelTwo diff --git a/src/services/dataService/__tests__/queries/dummyQuery.js b/src/services/dataService/__tests__/queries/dummyQuery.js new file mode 100644 index 0000000..86c436a --- /dev/null +++ b/src/services/dataService/__tests__/queries/dummyQuery.js @@ -0,0 +1,2 @@ +function dummyQuery() { return 'smiles' } +module.exports = dummyQuery diff --git a/src/services/dataService/__tests__/queries/dummyQueryTwo.js b/src/services/dataService/__tests__/queries/dummyQueryTwo.js new file mode 100644 index 0000000..5c5bf3f --- /dev/null +++ b/src/services/dataService/__tests__/queries/dummyQueryTwo.js @@ -0,0 +1,2 @@ +function dummyQueryTwo() { return 'joy' } +module.exports = dummyQueryTwo diff --git a/src/services/dataService/index.js b/src/services/dataService/index.js new file mode 100644 index 0000000..a2d73fd --- /dev/null +++ b/src/services/dataService/index.js @@ -0,0 +1,131 @@ +const thinky = require('thinky') +const rethinkdbdash = require('rethinkdbdash') + +const autoloadFunctions = require('../../util').autoloadFunctions + +function dataService(rethinkdb, options) { + if (!rethinkdb) { + throw new Error('rethinkdb parameter config or client is required') + } + if (typeof rethinkdb !== 'function' && typeof rethinkdb !== 'object') { + throw new Error('first parameter must be a config object or client') + } + + const r = typeof rethinkdb === 'function' ? + rethinkdb : rethinkdbdash(rethinkdb) + + const ds = {r} + const {models: modelDefinitions, queries: queryFunctions} = options || {} + if (modelDefinitions) { + let models + if (Array.isArray(modelDefinitions)) { + models = _loadModels(modelDefinitions, r) + } else if (typeof modelDefinitions === 'string') { + models = _loadModels(autoloadFunctions(modelDefinitions), r) + } else { + throw new Error('options.models must be a path to directory or array of model definition functions') + } + Object.assign(ds, models) + } + + if (queryFunctions) { + let queries + if (Array.isArray(queryFunctions)) { + queries = _transmuteFunctionArrayToObject(queryFunctions) + } else if (typeof queryFunctions === 'string') { + queries = autoloadFunctions(queryFunctions) + } else { + throw new Error('options.queries must be a path to directory or array of query functions') + } + Object.assign(ds, queries) + } + + return ds +} + +function _transmuteFunctionArrayToObject(arr) { + return Object.keys(arr).reduce((result, key) => { + if (typeof arr[key] === 'function') { + result[arr[key].name] = arr[key] + } + return result + }, {}) +} + +function _loadModels(modelDefinitions, r) { + const t = thinky({r, createDatabase: false}) + const errors = t.Errors + // initiate models + const models = {r, errors} + const modelDefs = {} + + Object.values(modelDefinitions).forEach((getModel) => { + const modelDefinition = getModel(t) || {} + const {name} = modelDefinition + modelDefs[name] = modelDefinition + + const model = _createModel(modelDefinition, t) + + models[name] = model + }) + // set associations now that all models have been instantiated + _associateModels(models, modelDefs) + + return models +} + +function _createModel(modelDefinition, t) { + const {table, schema, pk} = modelDefinition + const errors = t.Errors + + const model = t.createModel(table, schema, { + pk: pk || 'id', + table: {replicas: 1}, + enforce_extra: 'remove', // eslint-disable-line camelcase + init: false, + }) + + model.docOn('saving', (doc) => { + _updateTimestamps(doc) + }) + model.defineStatic('updateWithTimestamp', function (values = {}) { + return this.update(_updateTimestamps(values)) + }) + model.defineStatic('upsert', function (values = {}) { + const {id} = values || {} + if (!id) { + return this.save(values) + } + // {conflict: 'update'} option doesn't work when using .save() to update + // https://github.com/neumino/thinky/issues/454 + return this + .get(id) + .updateWithTimestamp(values) + .catch(errors.DocumentNotFound, () => this.save(values)) + }) + + if (modelDefinition.static) { + Object.keys(modelDefinition.static).forEach((staticFnName) => { + model.defineStatic(staticFnName, modelDefinition.static[staticFnName]) + }) + } + return model +} + +function _associateModels(models, modelDefs) { + Object.values(modelDefs).forEach((modelDef) => { + if (typeof modelDef.associate === 'function') { + const model = models[modelDef.name] + modelDef.associate(model, models) + } + }) +} + +function _updateTimestamps(values = {}) { + if (!values.updatedAt && typeof values !== 'function') { + values.updatedAt = new Date() + } + return values +} + +module.exports = dataService diff --git a/src/services/index.js b/src/services/index.js deleted file mode 100644 index e69de29..0000000 diff --git a/src/test/config.js b/src/test/config.js new file mode 100644 index 0000000..3ec417d --- /dev/null +++ b/src/test/config.js @@ -0,0 +1,34 @@ +/* eslint-disable import/no-unassigned-import, prefer-arrow-callback, +import/no-extraneous-dependencies */ +require('babel-core/register') +require('babel-polyfill') + +const jsdom = require('jsdom') +const chai = require('chai') +const chaiAsPromised = require('chai-as-promised') +const sinonChai = require('sinon-chai') + +// jsdom setup +const doc = jsdom.jsdom('') +const win = doc.defaultView + +global.document = doc +global.window = win +global.navigator = win.navigator +global.getComputedStyle = win.getComputedStyle + +// helpers +global.testContext = function (filename) { + return filename.slice(1).split('/').reduce(function (ret, curr) { + const currWithoutTests = curr === '__tests__' ? null : String.toString(`/${curr}`) + const value = ret.useCurr && currWithoutTests ? String(ret.value + currWithoutTests) : ret.value + const useCurr = ret.useCurr || curr === 'echo' + return {useCurr, value} + }, {useCurr: false, value: ''}).value.replace('.test.js', '').slice(1) +} + +// setup chai and make it available in all tests +chai.use(sinonChai) +chai.use(chaiAsPromised) +global.expect = chai.expect +global.assert = chai.assert diff --git a/src/test/mocha.opts b/src/test/mocha.opts new file mode 100644 index 0000000..33f4a32 --- /dev/null +++ b/src/test/mocha.opts @@ -0,0 +1 @@ +--require ./src/test/config diff --git a/src/util/index.js b/src/util/index.js new file mode 100644 index 0000000..02c319f --- /dev/null +++ b/src/util/index.js @@ -0,0 +1,30 @@ +const autoloader = require('auto-loader') + +function sleep(fn, time) { + return new Promise((resolve) => { + setTimeout(() => resolve(fn()), time) + }) +} + +function autoloadFunctions(directoryPath) { + const moduleExports = autoloader.load(directoryPath) + return Object.keys(moduleExports).reduce((result, key) => { + if (typeof moduleExports[key] === 'function') { + result[key] = moduleExports[key] + } + return result + }, {}) +} + +function truncateTables(r) { + return r.tableList() + .then(tables => tables.filter(table => !table.startsWith('_'))) + .then(tablesToTruncate => Promise.all(tablesToTruncate.map(table => + r.table(table).delete().run()))) +} + +module.exports = { + sleep, + truncateTables, + autoloadFunctions, +}