├── .eslintignore ├── .eslintrc.json ├── .gitignore ├── .vscode ├── last.sql ├── launch.json └── settings.json ├── README.md ├── app.js ├── client ├── assets │ └── favicon.png ├── scripts │ └── app.jsx └── styles │ └── app.scss ├── common ├── config.js ├── index.js ├── package.json ├── utils.js └── webpack.config.js ├── controllers ├── ApiController.js ├── Controller.js ├── UserApiController.js ├── UserController.js ├── index.js └── package.json ├── database.json ├── database ├── Database.js ├── MariaDatabase.js ├── MongoDatabase.js ├── index.js └── package.json ├── gulpfile.js ├── jsconfig.json ├── middleware ├── authenticator.js ├── index.js ├── package.json └── responder.js ├── migrations ├── nosql │ └── 20171020063709-create-comment-indexes.js └── sql │ ├── 20171020063353-create-products.js │ ├── 20171020063500-create-warehouses.js │ ├── 20171020074014-create-join-product-warehouse.js │ └── sqls │ ├── 20171020063353-create-products-down.sql │ ├── 20171020063353-create-products-up.sql │ ├── 20171020063500-create-warehouses-down.sql │ ├── 20171020063500-create-warehouses-up.sql │ ├── 20171020074014-create-join-products-warehouses-down.sql │ └── 20171020074014-create-join-products-warehouses-up.sql ├── models ├── Comment.js ├── MariaModel.js ├── Model.js ├── MongoModel.js ├── Product.js ├── User.js ├── Warehouse.js ├── index.js ├── models.d.ts └── package.json ├── package-lock.json ├── package.json ├── tests ├── dbTest.js └── modelsTest.js └── views ├── error.pug ├── index.pug ├── layout.pug └── notfound.pug /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | .vscode/ -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "parserOptions": { 3 | "ecmaVersion": 8, 4 | "sourceType": "module", 5 | "ecmaFeatures": { 6 | "jsx": true 7 | } 8 | }, 9 | "env": { 10 | "browser": true, 11 | "node": true, 12 | "amd": true, 13 | "commonjs": true, 14 | "mocha": true 15 | }, 16 | "globals": { 17 | "react": true 18 | }, 19 | "plugins": [ 20 | "react" 21 | ], 22 | "rules": { 23 | "for-direction": 1, 24 | "no-await-in-loop": 0, 25 | "no-compare-neg-zero": 1, 26 | "no-cond-assign": 1, 27 | "no-console": 0, 28 | "no-constant-condition": 1, 29 | "no-control-regex": 0, 30 | "no-debugger": 0, 31 | "no-dupe-args": 1, 32 | "no-dupe-keys": 1, 33 | "no-duplicate-case": 1, 34 | "no-empty": 1, 35 | "no-empty-character-class": 0, 36 | "no-ex-assign": 1, 37 | "no-extra-boolean-cast": 1, 38 | "no-extra-parens": 0, 39 | "no-extra-semi": 1, 40 | "no-func-assign": 1, 41 | "no-inner-declarations": 0, 42 | "no-invalid-regexp": 1, 43 | "no-irregular-whitespace": 1, 44 | "no-obj-calls": 0, 45 | "no-prototype-builtins": 0, 46 | "no-regex-spaces": 1, 47 | "no-sparse-arrays": 0, 48 | "no-template-curly-in-string": 1, 49 | "no-unexpected-multiline": 1, 50 | "no-unreachable": 1, 51 | "no-unsafe-finally": 0, 52 | "no-unsafe-negation": 1, 53 | "use-isnan": 1, 54 | "valid-jsdoc": 1, 55 | "valid-typeof": 1, 56 | 57 | "strict": 0, 58 | 59 | "no-undef": 0, 60 | "no-undef-init": 2, 61 | "no-undefined": 1, 62 | "no-unused-vars": 0, 63 | "no-use-before-define": 2, 64 | 65 | "global-require": 0, 66 | "handle-callback-err": 1, 67 | "no-buffer-constructor": 1, 68 | 69 | "array-bracket-spacing": 0, 70 | "brace-style": 1, 71 | "max-depth": 1, 72 | "max-nested-callbacks": 1, 73 | "linebreak-style": 0, 74 | "no-lonely-if": 1, 75 | 76 | "constructor-super": 1, 77 | "generator-star-spacing": 0, 78 | "no-this-before-super": 1, 79 | "no-var": 1, 80 | "object-shorthand": 0, 81 | "prefer-const": 0 82 | } 83 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | .git/ 3 | .DS_STORE/ 4 | client/dist -------------------------------------------------------------------------------- /.vscode/last.sql: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/megakoresh/koa2-react/8c0f891d804de82d89f321180e2e1602e014ce6a/.vscode/last.sql -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible Node.js debug attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "type": "chrome", 9 | "request": "attach", 10 | "name": "Attach to Chrome", 11 | "port": 9222, 12 | "webRoot": "${workspaceRoot}" 13 | }, 14 | { 15 | "type": "chrome", 16 | "request": "launch", 17 | "name": "Launch Chrome", 18 | "url": "http://localhost:3000", 19 | "webRoot": "${workspaceRoot}/client" 20 | 21 | }, 22 | { 23 | "type": "node", 24 | "request": "launch", 25 | "name": "Launch Program", 26 | "program": "${file}", 27 | "console": "integratedTerminal" 28 | }, 29 | { 30 | "type": "node", 31 | "request": "attach", 32 | "name": "Attach to Process", 33 | "port": 9229 34 | } 35 | ] 36 | } -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | {} -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # A boilerplate project for writing applications using Koa2 and React 2 | 3 | ## What? 4 | I became disillusioned with frameworks for Node.js, so here is a comprehensive MVC boilerplate project with most of the modern application components implemented in a consistent code style without any frameworks (well, on backend anyway). Note that this stack assumes you have intermediate to advanced understanding of Node.js. It is not for beginners. 5 | 6 | ### Stack supports 7 | - Koa2 (i.e. async await - you need Node.js 7.6, 8+ highly recommended for debugging with inspector protocole) 8 | - React for frontend using Webpack 9 | - Modules 10 | - Async functions 11 | - Gulp asset pipeline (disabled by default - may conflict with some of the ports webpack uses) 12 | - MongoDB 13 | - MariaDB/MySQL 14 | - Migrations 15 | - SASS css preprocessor (uses .scss syntax by default) 16 | - Basic security (CSRF) 17 | - Both ejs and pug view engines 18 | 19 | ## How? 20 | Get started with 21 | ``` 22 | git clone https://github.com/megakoresh/koa2-react.git yourprojectname 23 | cd yourprojectname 24 | rm -rf .git 25 | git init . 26 | ``` 27 | You now have the code base for a new project. The base classes are documented as to what they do. Project is meant to allow easy extensibility without breaking code style. You might want to 28 | - Inject ORM (e.g. Sequelize or Mongoose) - wrap it with Database class methods, or Model class methods, then extend from it. 29 | - Security - place it in middleware package, then use where needed 30 | - Policies - Add them as static methods to Authenticator, then use them when setting routes in controllers 31 | - Foundation/Bootstrap - see comments in webpack config 32 | - Migrations - `npm install -g db-migrate` and `db-migrate create:sql my-new-migration -e test-maria`, see https://db-migrate.readthedocs.io/en/latest/ for details 33 | - Global configuration - place it to common package where appropriate, then require. For database-related things, place to database package 34 | - File uploads - set the `{multipart: true}` option for the bodyparser 35 | 36 | I highly recommend you use Visual Studio Code for developing Node.js applications and this one in particular. It already includes launch configurations for both frontend and backend debugging, and in general VSCode is nowadays the best Node.js development IDE. You will save a lot of time with it. 37 | 38 | ## Why? 39 | I have used Sails.js for a long time, and in every single project, I spent more time fighting Sails and specifically - Waterline than building the actual application. I have used Meteor several times, and in every project when I wanted to change small details that made a lot of difference, I found it was impossible because "That's not how Meteor works". Same thing with Hapi. In the end, when you are building a more or less unique project (i.e. not yet another blog or web store or another kind of template site), you'll run into the framework's limitations sooner rather than later. Yes, building something without framework makes it more verbose, but with modern Javascript techniques and consistent code conventions that can be an avantage as well. 40 | 41 | This project aims to give you a quick start by eliminating the tedium of setting up an application, as well as providing you with a reasonably flexible, yet consistent and robust coding style and architecture with proper error handling and an example of every boilerplate feature both in implementation and usage. It is *not* a generic solution that will cover every possible use case. It is a solution that will help cover *your* use case. 42 | 43 | ## FAQ 44 | 45 | - Why does it not have [insert feature x here]? 46 | - Because not everyone needs it. It is probably fairly straighforward to add this feature though. 47 | - I found a design problem/bug/suggestion 48 | - Put it to issues. 49 | - What would be the recommended way of adding feature X? 50 | - If it's not obvious and the feature is not very niche, then it's a design flaw, put the question to issues. 51 | - Why React? 52 | - In my opinion it's the most complicated to set up. You can swap React for Vue in the project simply by replacing webpack plugins and loaders. You can't do it another way around. And Angular 2 has it's own ecosystem. You can easily enable TypeScript on this project, but I didn't want to make it a default. 53 | - Koa1/Express/Node.js < 7.6? 54 | - No. 55 | - Why local modules? 56 | - To avoid `../../../relative.hell.js` and ensure consistent loading of each module (in `index.js`, you can ensure some logic always runs before module is loaded). It's similar to Java packages in a way. 57 | - Why is gulp disabled? 58 | - Ran into problems running it together with webpack (or webpack through gulp to be more precise). Didn't see enough benefit to it, so left it alone. I think gulp is a good way to prepare and optimize assets before deployment to production, but Webpack is better for development. 59 | 60 | 61 | ## TODO 62 | - Some kind of visual diagram for how the project is organized when I have time. 63 | - Some actual boilerplate functionality for React/Redux 64 | 65 | ## License 66 | MIT 67 | -------------------------------------------------------------------------------- /app.js: -------------------------------------------------------------------------------- 1 | const Koa = require('koa'); 2 | const CSRF = require('koa-csrf'); 3 | const bodyParser = require('koa-body'); 4 | const jwt = require('koa-jwt'); 5 | const serve = require('koa-static'); 6 | const path = require('path'); 7 | const webpack = require('koa-webpack'); 8 | const Router = require('koa-router'); 9 | 10 | const models = require('models'); 11 | const controllers = require('controllers'); 12 | const middleware = require('middleware'); 13 | const common = require('common'); 14 | 15 | const { config, Logger, Utils, webpackConfig } = common; 16 | const { Responder } = middleware; 17 | 18 | const app = new Koa(); 19 | //app.proxy = true; //this is needed if running from behind a reverse proxy 20 | 21 | //more info https://github.com/shellscape/koa-webpack 22 | if(config.env !== 'production'){ 23 | app.use(webpack({ config: webpackConfig })); 24 | if(!process.env['NO_STATIC']) 25 | app.use(serve(path.join(config.appRoot, 'client', 'dist'))); 26 | } 27 | 28 | app.use(Responder.middleware); 29 | //note: by default multipart requests are not parsed. More info: https://github.com/dlau/koa-body 30 | app.use(bodyParser()); 31 | app.use(new CSRF(config.csrf)); 32 | 33 | controllers.load(app); 34 | 35 | app.listen(3000); 36 | 37 | Logger.info('Application running on port 3000'); -------------------------------------------------------------------------------- /client/assets/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/megakoresh/koa2-react/8c0f891d804de82d89f321180e2e1602e014ce6a/client/assets/favicon.png -------------------------------------------------------------------------------- /client/scripts/app.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | 4 | class App extends React.Component { 5 | render(){ 6 | return ( 7 | 8 | ); 9 | } 10 | } 11 | 12 | console.log('Is this thing actually running?'); 13 | 14 | ReactDOM.render( 15 | , 16 | document.querySelector('menu') 17 | ); 18 | 19 | /* 20 | Supposedly you can enable hot module reload this way to the modules you 21 | define above. I have not tested it yet though. To use it with react 22 | this is recommended to be used: https://github.com/gaearon/react-hot-loader 23 | if (module.hot) { 24 | module.hot.accept('./othermodule.js', function() { 25 | console.log('Accepting the updated printMe module!'); 26 | printMe(); 27 | }) 28 | } */ -------------------------------------------------------------------------------- /client/styles/app.scss: -------------------------------------------------------------------------------- 1 | html { 2 | font-family: 'Open Sans', sans-serif; 3 | } 4 | 5 | aside { 6 | color: gray; 7 | } -------------------------------------------------------------------------------- /common/config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | 3 | module.exports = { 4 | csrf: { 5 | "invalidSessionSecretMessage": "Invalid session secret", 6 | "invalidSessionSecretStatusCode": 403, 7 | "invalidTokenMessage": "Invalid CSRF token", 8 | "invalidTokenStatusCode": 403, 9 | "excludedMethods": [ "GET", "HEAD", "OPTIONS" ], 10 | "disableQuery": false 11 | }, 12 | keys: { 13 | session: process.env['SESSION_SECRET'] || 'session-secret' 14 | }, 15 | appRoot: __dirname.split(path.sep).slice(0,-1).join(path.sep), 16 | env: process.env['NODE_ENV'] 17 | } 18 | -------------------------------------------------------------------------------- /common/index.js: -------------------------------------------------------------------------------- 1 | const logger = require('winston'); 2 | logger.remove(logger.transports.Console); 3 | logger.add(logger.transports.Console, { level: 'debug', colorize:true }); 4 | //maintain this order to avoid any circular dependency BS 5 | exports.Logger = logger; 6 | exports.config = require('./config'); 7 | exports.Utils = require('./utils'); 8 | exports.webpackConfig = require('./webpack.config'); -------------------------------------------------------------------------------- /common/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "common", 3 | "version": "0.0.0", 4 | "description": "Code used elsewhere in the app" 5 | } 6 | -------------------------------------------------------------------------------- /common/utils.js: -------------------------------------------------------------------------------- 1 | /*eslint { "global-require": 0 }*/ 2 | const path = require('path'); 3 | const fs = require('fs'); 4 | const config = require('./config'); 5 | const { Logger } = require('common'); 6 | 7 | class Utils { 8 | static getFiles(directory, ignoreRegex) { 9 | 10 | } 11 | 12 | static requireFolder(folderPath, ignoreRegex) { 13 | if (ignoreRegex && !(ignoreRegex instanceof RegExp)) throw new Error(`$Argument ${ignoreRegex} was not a regular expression! Must be a regular expression that matches filenames to be ignored`); 14 | 15 | const modules = {}; 16 | 17 | const directory = path.join(config.appRoot, folderPath); 18 | 19 | const files = require('fs').readdirSync(directory) 20 | if (files.includes('index.js')) { 21 | if (ignoreRegex && !ignoreRegex.test('index.js')) 22 | Loggerwarn(`${directory} includes an index.js file that the passed ignoreRegex ${ignoreRegex} does not match. \nThis means it will be required along with other files. That may not be what you want.`); 23 | else return require(directory); 24 | } 25 | 26 | if (!ignoreRegex) ignoreRegex = /(index)(\.js)/; 27 | 28 | const dirStats = fs.statSync(folderPath); //throws if the directory doesnt exist or permission is denied 29 | 30 | if (!dirStats.isDirectory()) throw new Error(`${folderPath} is not a directory!`); 31 | 32 | function* walk(directory) { 33 | const files = fs.readdirSync(directory); 34 | for (let i = 0; i < files.length; i++) { 35 | let file = files[i]; 36 | if (file.match(ignoreRegex)) continue; 37 | const stat = fs.statSync(path.join(directory, file)); 38 | if (stat.isDirectory()) yield* walk(path.join(directory, file)); 39 | else yield path.join(directory, file); 40 | } 41 | } 42 | 43 | const iterator = walk(directory); 44 | let result = iterator.next(); 45 | while (!result.done) { 46 | /* Store module with its name (from filename) */ 47 | modules[path.basename(result.value, '.js')] = require(result.value); 48 | result = iterator.next(); 49 | } 50 | return modules; 51 | } 52 | 53 | static requireNamespace(folderPath, namespace) { 54 | const modules = {}; 55 | 56 | const directory = path.join(config.appRoot, folderPath); 57 | 58 | const files = require('fs').readdirSync(directory) 59 | 60 | let ignoreRegex = /(index)(\.js)/; 61 | 62 | const dirStats = fs.statSync(folderPath); //throws if the directory doesnt exist or permission is denied 63 | 64 | if (!dirStats.isDirectory()) throw new Error(`${folderPath} is not a directory!`); 65 | 66 | function* walk(directory) { 67 | const files = fs.readdirSync(directory); 68 | for (let i = 0; i < files.length; i++) { 69 | let file = files[i]; 70 | if (file.match(ignoreRegex)) continue; 71 | const stat = fs.statSync(path.join(directory, file)); 72 | if (stat.isDirectory()) yield* walk(path.join(directory, file)); 73 | else yield path.join(directory, file); 74 | } 75 | } 76 | 77 | const iterator = walk(directory); 78 | let result = iterator.next(); 79 | while (!result.done) { 80 | let m = require(result.value); 81 | if (m[namespace]) { 82 | //store potentially incomplete reference containing the namespace here 83 | modules[path.basename(result.value, '.js')] = m; 84 | } 85 | result = iterator.next(); 86 | } 87 | 88 | //Now bring the namespaced modules to top after all have been required 89 | //this should resolve circular dependency problems if any 90 | const moduleNames = Object.keys(modules); 91 | for (let moduleName of moduleNames) { 92 | modules[moduleName] = modules[moduleName][namespace]; 93 | } 94 | 95 | return modules; 96 | } 97 | 98 | static get DEF_GLOBAL_PROXY_HANDLER() { 99 | return { 100 | get(target, name) { 101 | if (name in target) return target[name]; 102 | else throw new ReferenceError(`This global object has no enumerable property "${name}"`); 103 | } 104 | } 105 | } 106 | 107 | static installGlobal(object, name) { 108 | if (Object.keys(global).includes(name)) throw new Error(`A variable by "${name}" already exists at global scope, choose a different name!`); 109 | //create a proxy to watch changes and prevent pollution 110 | const proxy = new Proxy(object, Utils.DEF_GLOBAL_PROXY_HANDLER); 111 | global[name] = proxy; 112 | } 113 | 114 | static requireModels() { 115 | let models = Utils.requireNamespace('data', 'model'); 116 | return models; 117 | } 118 | 119 | static getObjectClassName(object) { 120 | if(object.constructor.name === 'Function' && object.name) 121 | return object.name; 122 | if(object.constructor.name !== 'Object') 123 | return object.constructor.name; 124 | else 125 | return object.toString().split('(' || /s+/)[0].split(' ' || /s+/)[1]; 126 | } 127 | 128 | static flatten(array) { 129 | return array.reduce((a, b) => a.concat(b), []); 130 | } 131 | 132 | static isBasicType(value) { 133 | return Object.keys(value) === 0 || typeof value === 'string'; 134 | } 135 | 136 | /** 137 | * Merge two arrays, updating entries in first with corresponding entries from second array when comparatr returns true 138 | * and appending the rest of the entries from the second array to end of the first, returning array1, modified in palce 139 | * @param {Array} array1 the array to merge with 140 | * @param {Array} array2 the array to merge to 141 | * @param {Function} comparator function taking two parameters, the first is value from array1, second value from array2, that will determine whether to merge values or append 142 | * @returns {Array} array1, modified 143 | */ 144 | static arrayMerge(array1, array2, comparator){ 145 | if(typeof comparator !== 'function') throw new Error('merger must be a function'); 146 | for(let i=0; icomparator(val, array2[i])); 148 | if(found>-1) { 149 | array1[found] = array2[i]; 150 | } else { 151 | array1.push(array2[i]); 152 | } 153 | } 154 | return array1; 155 | } 156 | 157 | /** 158 | * Returns a generator that will iterate an object's enumerable properties, compatible with for..of loops 159 | * @param {*} object whose enumerable properties will be iterated 160 | * @returns {Generator} a generator that conforms to the iterator protocol 161 | */ 162 | static *iterateObject(object){ 163 | for(let key in object){ 164 | if(object.hasOwnProperty(key)){ 165 | yield [ key, object[key] ]; 166 | } 167 | } 168 | } 169 | 170 | /** 171 | * Checks if the passed object is a JSON-serializable data object. 100% legit no fake (2017) 172 | * @param {*} object to test for legitimacy 173 | * @returns {Boolean} true if legit, false if busted 174 | */ 175 | static legitObject(object){ 176 | //seems legit 177 | return typeof object === 'object' && object.constructor === Object && JSON.stringify(object); 178 | } 179 | 180 | /** 181 | * The true God. 182 | * @param {Array} from the universe 183 | * @param {Boolean} remove humbly request thy object be freed of the physical 184 | * @returns {*} the chosen one to echo through the ages 185 | */ 186 | static selectRandom(from, remove){ 187 | if (!Array.isArray(from)) throw new Error("from must be an array of choices! Was " + from); 188 | if (from.length === 0) throw new Error("Can't select from an empty array"); 189 | let rand = Math.round(Utils.random(0, from.length - 1)); 190 | let value = from[rand]; 191 | if (remove === true) { 192 | from.splice(rand, 1); 193 | } 194 | return value; 195 | } 196 | 197 | static random(min, max){ 198 | return min + ((max-min) * Math.random()); 199 | } 200 | 201 | static generateArray(times, generator, ...args){ 202 | const result = []; 203 | for(let i = 0; i0) return router; //don't bind the routes second time 14 | router.get('/api', Authenticator.login, this.api); 15 | return router; 16 | } 17 | } 18 | 19 | module.exports = ApiController; -------------------------------------------------------------------------------- /controllers/Controller.js: -------------------------------------------------------------------------------- 1 | const Router = require('koa-router'); 2 | 3 | const router = new Router(); 4 | 5 | class Controller { 6 | constructor(){ 7 | throw new Error('Controllers are static in this implementation and can\'t be instantiated'); 8 | } 9 | 10 | static index(ctx, next){ 11 | ctx.view('index.pug',{ 12 | list: [ 13 | { 14 | name: 'Fix database', 15 | completed: true, 16 | }, 17 | { 18 | name: 'Fix models', 19 | completed: true 20 | }, 21 | { 22 | name: 'Fix controllers', 23 | completed: true 24 | }, 25 | { 26 | name: 'Fix clientside', 27 | completed: true 28 | } 29 | ] 30 | }); 31 | } 32 | /** 33 | * Binds the controller's routes to a router and returns it 34 | * @returns {Router} the router instance with this controller's routes bound 35 | */ 36 | static get router(){ 37 | if(router.stack.length>0) return router; //don't bind the routes second time 38 | router.get('index', '/', Controller.index); 39 | return router; 40 | } 41 | } 42 | 43 | module.exports = Controller; -------------------------------------------------------------------------------- /controllers/UserApiController.js: -------------------------------------------------------------------------------- 1 | const Router = require('koa-router'); 2 | const ApiController = require('./ApiController'); 3 | const { Authenticator } = require('middleware'); 4 | 5 | const router = new Router(); 6 | 7 | class UserApiController extends ApiController { 8 | static async getUserSwag(ctx, next){ 9 | return ctx.json({swag: 'Nope. No swag.'}); 10 | } 11 | 12 | static get router(){ 13 | if(router.stack.length>0) return router; //don't bind the routes second time 14 | router.get('/api/user/:id/swag', Authenticator.login, this.getUserSwag); 15 | return router; 16 | } 17 | } 18 | 19 | exports.controller = UserApiController; -------------------------------------------------------------------------------- /controllers/UserController.js: -------------------------------------------------------------------------------- 1 | const Router = require('koa-router'); 2 | const Controller = require('./Controller'); 3 | 4 | const router = new Router(); 5 | 6 | class UserController extends Controller { 7 | static get router(){ 8 | if(router.stack.length>0) return router; //don't bind the routes second time 9 | router.post('/profile/:id', this.profile); 10 | router.post('/logout', this.logout); 11 | return router; 12 | } 13 | 14 | static async profile(ctx, next){ 15 | //if the authenticate worked, user info should be in ctx.state 16 | } 17 | 18 | static async logout(ctx, next){ 19 | 20 | } 21 | } 22 | 23 | exports.controller = UserController; -------------------------------------------------------------------------------- /controllers/index.js: -------------------------------------------------------------------------------- 1 | const Koa = require('koa'); 2 | const { Utils, Logger } = require('common'); 3 | 4 | const Controller = require('./Controller'); 5 | const ApiController = require('./ApiController'); 6 | const controllers = Utils.requireNamespace('controllers', 'controller'); 7 | /** 8 | * One interesting idea is to create controller instances for every user session 9 | * In this case controllers must export a function that takes ctx and next, like middleware, 10 | * which checks for session/token hash in ctx and if found, either retrieves or creates an 11 | * instance for it, that is kept between requests. That is, however, against the stateless 12 | * conventions of SPAs, so not implemented here, though architecture supports it. 13 | * 14 | * If that is done, the the class hierarchy would actually make more sense. Right now 15 | * it is nothing more than an ogranizational mechanism. 16 | */ 17 | 18 | exports.load = function(app){ 19 | if(!(app instanceof Koa)) throw new TypeError('Please provide your app instance so controllers can load their routes'); 20 | 21 | Logger.info('Loading static routes'); 22 | app.use(Controller.router.routes()); 23 | app.use(Controller.router.allowedMethods()); 24 | 25 | Logger.info('Loading static api routes'); 26 | app.use(ApiController.router.routes()); 27 | app.use(ApiController.router.allowedMethods()); 28 | 29 | for (let [name, controller] of Utils.iterateObject(controllers)){ 30 | Logger.info(`Loading ${name}`); 31 | app.use(controller.router.routes()); 32 | app.use(controller.router.allowedMethods()); 33 | 34 | } 35 | return app; 36 | } -------------------------------------------------------------------------------- /controllers/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "controllers", 3 | "version": "0.0.0", 4 | "description": "Controllers for this app" 5 | } -------------------------------------------------------------------------------- /database.json: -------------------------------------------------------------------------------- 1 | { 2 | "test-mariadb": { 3 | "driver": "mysql", 4 | "database": "test", 5 | "user": { "ENV" : "MARIA_TEST_USER" }, 6 | "password" : { "ENV" : "MARIA_TEST_PASS" }, 7 | "host": { "ENV" : "MARIA_TEST_HOST" }, 8 | "multipleStatements": true 9 | }, 10 | "test-mongodb": { 11 | "driver": "mongodb", 12 | "database": "test", 13 | "user": { "ENV" : "MONGO_TEST_USER" }, 14 | "password" : { "ENV" : "MONGO_TEST_PASS" }, 15 | "host": { "ENV" : "MONGO_TEST_HOST" } 16 | } 17 | } -------------------------------------------------------------------------------- /database/Database.js: -------------------------------------------------------------------------------- 1 | const { Logger } = require('common'); 2 | 3 | /** 4 | * This is your database wrapper. Your models use this as a standardized interface to your database driver 5 | * which lets you change databases easily as well as have a cleaner codebase. 6 | * 7 | * If you use some kind of ORM like Sequelize or Mongoose, this class is recommended as an injection 8 | * for those - simply use the ORM instead of the database driver. I recommend subclassing database to 9 | * e.g. SequelizeDatabase and implementing the methods listed as wrappers around corresponding Sequelize 10 | * methods. For example connect() would then return result of new Sequelize('database', 'username', 'password'). 11 | * 12 | * Your model should then provide the result of ORMs schema which the query methods like find and where should accept. 13 | * Or just ignore what I said and make your own implementation. 14 | */ 15 | module.exports = 16 | class Database { 17 | constructor(url){ 18 | this.url = url; 19 | this.listerners(); 20 | } 21 | 22 | listerners(){ 23 | process.on('exit', this.disconnect); 24 | process.on('SIGINT', this.disconnect); 25 | process.on('SIGUSR1', this.disconnect); 26 | process.on('SIGUSR2', this.disconnect); 27 | } 28 | 29 | //return some kind of result set from the query object passed 30 | async select(query){ 31 | throw new Error('Database.select is abstract and must be implemented by subclasses'); 32 | } 33 | 34 | //insert records using the query and return something 35 | async insert(query){ 36 | throw new Error('Database.insert is abstract and must be implemented by subclasses'); 37 | } 38 | 39 | //update records using the query and return something 40 | async update(query){ 41 | throw new Error('Database.update is abstract and must be implemented by subclasses'); 42 | } 43 | 44 | //delete records using the query and return something 45 | async delete(args){ 46 | throw new Error('Database.delete is abstract and must be implemented by subclasses'); 47 | } 48 | 49 | async count(args){ 50 | throw new Error('Database.count is abstract and must be implemented by subclasses'); 51 | } 52 | 53 | /** 54 | * Connects to the database. When overriding this method DO NOT EVER perform a connection or any 55 | * async or long-running operation because if you follow conventions of the template, this method 56 | * will be called on EVERY database operation. So cache the database instance in the pool once the 57 | * first connection is performed and simply retreive the reference by some identifier (conventionally db url) 58 | * when this method is called subsequently. 59 | * @param {*} args arguments your overridden method will need, this will be same as what you pass to ensureConnected() in find, where, etc. Conventionally this is database url or table/collection name 60 | * @returns {*} database object from your db driver that represents a connected database instance 61 | */ 62 | async connect(args){ 63 | throw new Error('Database.connect is abstract and must be implemented by subclasses'); 64 | } 65 | 66 | //free up a connection using the provided driver 67 | async disconnect(){ 68 | throw new Error('Database.disconnect is abstract and must be implemented by subclasses'); 69 | } 70 | } -------------------------------------------------------------------------------- /database/MariaDatabase.js: -------------------------------------------------------------------------------- 1 | const Database = require('./Database'); 2 | const mysql = require('mysql2/promise'); 3 | const { Utils, Logger } = require('common'); 4 | 5 | const CONNECTIONS = {}; 6 | const queryOptions = { 7 | //nestTables: true 8 | } 9 | 10 | //Wrapper around the query construction string, that does some validations and provides a consistent interface for simple queries 11 | //if more advanced functionality is needed, I recommend using something like Knex 12 | class SQLQuery { 13 | constructor(table, pk){ 14 | if(!table) throw new Error('Table must be given to query constructor'); 15 | this.table = table; 16 | this.pk = pk || 'id'; 17 | this.query = []; 18 | this.params = []; 19 | } 20 | 21 | get expectedParams(){ 22 | let matches = this.toString().match(/\?{1,2}/gmi); 23 | return matches ? matches.length : 0; 24 | } 25 | 26 | static _objectToClause(obj){ 27 | const compound = { clause: [], values: [] }; 28 | for(let [key, val] of Utils.iterateObject(obj)){ 29 | compound.clause.push(`${key} = ?`); 30 | compound.values.push(val); 31 | } 32 | return compound; 33 | } 34 | 35 | where(clause){ 36 | if(this.query.length === 0) this.query.push(`SELECT * FROM ${this.table}`); 37 | if(!clause) return this; 38 | 39 | this.query.push('WHERE\n'); 40 | if(Utils.legitObject(clause)){ 41 | let compound = SQLQuery._objectToClause(clause); 42 | this.query.push(compound.clause.join(' AND ')); 43 | this.values(...compound.values); 44 | } else if(Array.isArray(clause) && clause.every(sub=>Utils.legitObject(sub))) { 45 | const allCriteria = clause.map(sub => { 46 | let compound = SQLQuery._objectToClause(sub); 47 | return { clause: `(${compound.clause.join(' AND ')})`, values: compound.values }; 48 | }) 49 | this.query.push(allCriteria.map(cc=>cc.clause).join(' OR ')); 50 | this.values(...allCriteria.reduce((acc, curr) => acc.concat(curr.values), [])); 51 | } else if(typeof clause === 'string' || Number.isInteger(clause)) { 52 | if(typeof clause === 'string') this.query.push(clause); 53 | else this.query.push(`${this.pk} = ${clause}`); 54 | } else { 55 | throw new Error('Clause of where statement must be an object, an array of objects, as string or an integer row id') 56 | } 57 | this._where = this.query.length-1; 58 | return this; 59 | } 60 | 61 | insert(clause){ 62 | if(this.query.length !== 0) throw new Error(`Insert query must begin with insert clause, but something else was called before: ${this.query.join(' ')}`); 63 | this.query.push(`INSERT INTO ${this.table}\n`); 64 | this.query.push(clause); 65 | this._index = this.query.length - 2; 66 | return this; 67 | } 68 | 69 | join(joinType, otherTable, thisTableKey, otherTableKey, overrideThisTable){ 70 | const fkTable = overrideThisTable || this.table; 71 | if(this.query.length === 0) this.query.push(`SELECT * FROM ${this.table}`); 72 | let where; 73 | if(this._where){ 74 | console.warn(`Detected that where clause was attached BEFORE the join. That is bad, yes? Will try to reorder, but no promises`); 75 | where = this.query.splice(this._where, 1)[0]; 76 | } 77 | if(typeof thisTableKey === 'string' && typeof otherTableKey === 'undefined') 78 | this.query.push(`${joinType} ${otherTable} ON ${thisTableKey}`); 79 | else 80 | this.query.push(`${joinType} ${otherTable} ON ${otherTable}.${otherTableKey} = ${fkTable}.${thisTableKey}`); 81 | this._join = this.query.length-1; 82 | if(where) this.query.push(where); //if where was set BEFORE the join, reorder it to the end 83 | return this; 84 | } 85 | 86 | update(clause){ 87 | if(this.query.length !== 0) throw new Error(`Update queries must begin with update clause, but something else was called before: ${this.query.join(' ')}`); 88 | this.query.push(`UPDATE ${this.table}\n`); 89 | this.query.push(clause); 90 | this._update = this.query.length - 2; 91 | return this; 92 | } 93 | 94 | delete(){ 95 | if(this.query.length !== 0) throw new Error(`Delete queries must begin with delete clause, but something else was called before: ${this.query.join(' ')}`); 96 | this.query.push(`DELETE FROM ${this.table}\n`); 97 | this._delete = this.query.length -1; 98 | return this; 99 | } 100 | 101 | modifiers(clause){ 102 | if(this.query.length === 0) throw new Error('Modifiers like order by and limit are set at the end of the query!'); 103 | if(typeof clause !== 'string') throw new Error('Modifiers must be a string'); 104 | this.query.push(clause); 105 | this._modifiers = this.query.length -1; 106 | return this; 107 | } 108 | 109 | count(){ 110 | if(this.query.length !== 0) throw new Error(`Count queries must begin with count clause, but something else was called before: ${this.query.join(' ')}`); 111 | this.query.push(`SELECT COUNT(*) as count FROM ${this.table}\n`); 112 | this._count = this.query.length -1; 113 | return this; 114 | } 115 | 116 | values(...values){ 117 | if(this.expectedParams !== this.params.length + values.length) throw new Error(`Query so far does not support this number of parameters:\nexpected: ${this.expectedParams}\ngot: ${this.params.length + values.length}\nquery to far:\n${this.toString()}`); 118 | for(let value of values){ 119 | if(Utils.legitObject(value)){ 120 | if(Object.keys(value).some(key=>key.startsWith('_'))){ 121 | let keysToDelete = Object.keys(value).filter(key=>key.startsWith('_')); 122 | for(let key of keysToDelete){ 123 | delete value[key]; 124 | } 125 | } 126 | } 127 | if(Array.isArray(value) && value.length === 0){ 128 | throw new Error('Can not pass empty arrays as query values, please an array value must have at least one element'); 129 | } 130 | } 131 | this.params.push(...values); 132 | return this; 133 | } 134 | 135 | toString(){ 136 | return this.query.join(' '); 137 | } 138 | 139 | prepare(options){ 140 | if(!options) options = {}; 141 | const final = this.toString(); 142 | //nest tables on joined queries, since they have high chance at field collision, at least for IDs 143 | if(typeof options.nestTables === 'undefined' && this._join) { 144 | options.nestTables = true; 145 | } 146 | if(this.expectedParams !== this.params.length) throw new Error(`Number of parameters(${this.params.length}) did not match the number of placeholders in the prepared query(${placeholders.length})!`); 147 | const query = Object.keys(options).length > 0 ? Object.assign({ sql: final }, options) : final; 148 | if(this.params.length === 0) return [ query ]; 149 | else return [ query, this.params ]; 150 | } 151 | } 152 | 153 | module.exports = 154 | class MariaDatabase extends Database { 155 | constructor(url, inTransaction) { 156 | super(url, mysql); 157 | Logger.info(`Created a MariaDatabase instance ${inTransaction ? 'for a transaction' : `with a url ${url}`}`); 158 | } 159 | 160 | static get SQLQuery() { 161 | return SQLQuery; 162 | } 163 | 164 | //TODO: 165 | //strict mode - operate exactly with data provided, let mysql throw on schema errors. Also throw instead of just printing an error message in each method when result count is unexpected 166 | //relaxed mode - simply ignore (filter out) all fields that the table doesn't have and operate with reduced valid dataset(requires 1 extra query to fetch columns) 167 | async execute(...queries) { 168 | if(!queries.every(q=>q instanceof SQLQuery)) throw new Error('All queries must be instances of SQLQuery object'); 169 | const autoRelease = !this.connection; 170 | const connection = await this.connect(); 171 | 172 | const executing = []; 173 | for(let query of queries){ 174 | //Logger.verbose(`Executing\n${query.toString()}\nwith ${query.params.length} parameters`); 175 | executing.push(connection.query(...query.prepare())); 176 | } 177 | let result = await Promise.all(executing); 178 | if (autoRelease) connection.release(); 179 | return queries.length === 1 ? result[0] : result; 180 | } 181 | 182 | async select(table, where, ...params) { 183 | const query = new SQLQuery(table).where(where).values(...params); 184 | const results = await this.execute(query); 185 | return results; 186 | } 187 | 188 | async insert(table, ...data) { 189 | if(data.length === 0) throw new Error('Tried to insert empty dataset (no second argument on insert)'); 190 | if(data.length === 1 && Array.isArray(data[0])) data = data[0]; 191 | let queries = []; 192 | let clause = 'SET ?'; 193 | if(typeof data[0] === 'string') { 194 | clause = data[0]; 195 | data = data.slice(1); 196 | } 197 | for(let row of data){ 198 | if(!Utils.legitObject(row) && !Array.isArray(row)) throw new TypeError(`One of objects to insert passed to db.insert method was not a serializable data object: ${row}`); 199 | queries.push(new SQLQuery(table).insert(clause).values(row)); 200 | } 201 | const results = await this.execute(...queries); 202 | return results; 203 | } 204 | 205 | async update(table, data, where, ...params) { 206 | const query = new SQLQuery(table).update('SET ?').values(data).where(where); 207 | if(params) query.values(...params); 208 | const results = await this.execute(query); 209 | return results; 210 | } 211 | 212 | async updateMultiple(table, data, whereFn){ 213 | if(!Array.isArray(data)) throw new Error('updateMultiple can only accept arrays of objects to update'); 214 | if(typeof whereFn !== 'function') throw new Error('whereFn must be a function that sets the where condition for each record'); 215 | const queries = data 216 | .map((dd, index)=>{ 217 | let finalQuery = whereFn(new SQLQuery(table).update('SET ?').values(dd), dd, index); 218 | if(!(finalQuery instanceof SQLQuery)) throw new Error(`whereFn must set the where clause of the query object and return it. Returned instead ${finalQuery}`); 219 | return finalQuery; 220 | }); 221 | const results = await this.execute(...queries); 222 | if(results.length !== data.length) Logger.error(`Results length on updateEach did not match the object count of ${data.length}, likely an error has occurred!`); 223 | return results; 224 | } 225 | 226 | async delete(table, where, ...params) { 227 | const query = new SQLQuery(table).delete().where(where).values(...params); 228 | const results = await this.execute(query); 229 | return results; 230 | } 231 | 232 | async count(table, where, ...params) { 233 | const query = new SQLQuery(table).count(); 234 | if(where) query.where(where); 235 | if(params) query.values(...params); 236 | const results = await this.execute(query); 237 | return results[0][0].count; 238 | } 239 | 240 | async connect() { 241 | if(this.dead) throw new Error(`This instance of MariaDatabase was cloned for a transaction and is now dead, please use original`); 242 | if(this.connection) return this.connection; 243 | if (!CONNECTIONS[this.url]) { 244 | let pool = mysql.createPool(this.url+'?multipleStatements=true&connectionLimit=100'); //to debug add &debug=[\'ComQueryPacket\'] to the url 245 | //let pool = mysql.createPool(this.url+'?multipleStatements=true&connectionLimit=100&debug=[\'ComQueryPacket\']'); //to debug add &debug=[\'ComQueryPacket\'] to the url 246 | CONNECTIONS[this.url] = pool; 247 | } 248 | return await CONNECTIONS[this.url].getConnection(); 249 | } 250 | 251 | async disconnect() { 252 | if(!CONNECTIONS[this.url]) return; 253 | Logger.info(`Closing ${CONNECTIONS[this.url]}`); 254 | await CONNECTIONS[this.url].end(); 255 | delete CONNECTIONS[this.url]; 256 | } 257 | 258 | static async disconnect(){ 259 | Logger.info('Closing all MariaDatabase connections for all instances'); 260 | for (let [url, pool] of Utils.iterateObject(CONNECTIONS)) { 261 | Logger.info(`Closing ${url}`); 262 | await pool.end(); 263 | delete CONNECTIONS[url]; 264 | } 265 | } 266 | 267 | /** 268 | * Runs operations defined in the provided function callback in a transaction and commits it automatically, or rolls it back on any error 269 | * @param {MariaDatabase~transactionCallback} transactionFn function that runs operations in transaction and returns a promise that resolves/rejects when the transaction is complete/failed 270 | * @returns {Promise} promise that resolves to a two-value array, where array[0] is what your transactionCallback returned, or null and array[1] is an error if any occurred. 271 | */ 272 | transaction(transactionFn){ 273 | if(typeof transactionFn !== 'function') return Promise.reject(new TypeError('Transaction takes a promise-returning function.')); 274 | const transactionInstance = new MariaDatabase(this.url, true); 275 | return transactionInstance.connect() 276 | .then(connection=>{ 277 | return connection.beginTransaction() 278 | .then(()=>{ 279 | transactionInstance.connection = connection; 280 | let promise = transactionFn(transactionInstance, connection); 281 | if(typeof promise.then !== 'function'){ 282 | throw new Error('Transaction callback must return a promise!'); 283 | } 284 | return promise; 285 | }) 286 | //propagate the result of user's callback to the end of chain 287 | .then((result)=>connection.commit().then(()=>[result, null])) 288 | .catch((err)=>{ 289 | connection.rollback(); 290 | Logger.error(`Rolled back transaction due to error:\n ${err.stack}`); 291 | return [null, err]; 292 | }) 293 | //finally - release connection and mark the cloned instance used 294 | .then((result)=>{ 295 | transactionInstance.connection.release(); 296 | transactionInstance.connection = null; 297 | transactionInstance.dead = true; 298 | process.removeListener('exit', this.disconnect); 299 | process.removeListener('SIGINT', this.disconnect); 300 | process.removeListener('SIGUSR1', this.disconnect); 301 | process.removeListener('SIGUSR2', this.disconnect); 302 | return result; 303 | }); 304 | }); 305 | } 306 | /** 307 | * This function must contain all operations performed during the transaction. The transaction will be automatically commited if no errors occurred, or rolled back if any do. 308 | * @callback MariaDatabase~transactionCallback 309 | * @param {MariaDatabase} db temporary database instance to use during transaction (a copy of the one used to launch transaction) 310 | * @param {mysql.Connection} connection the connection object from mysql2 driver that will perform the transaction, equal to db.connection 311 | * @returns {Promise} promise that resolves when all transaction operations have completed. You may throw or return a rejected promise to trigger rollback 312 | */ 313 | } -------------------------------------------------------------------------------- /database/MongoDatabase.js: -------------------------------------------------------------------------------- 1 | const Database = require('./Database'); 2 | const mongodb = require('mongodb'); 3 | const { Logger } = require('common'); 4 | 5 | //static pool that holds connections for all instances of the database 6 | const CONNECTION_POOL = {}; 7 | 8 | /** 9 | * An example of implementing a MongoDB interface using the default template structure 10 | */ 11 | module.exports = 12 | class MongoDatabase extends Database { 13 | constructor(url){ 14 | super(url, mongodb); 15 | } 16 | 17 | async select(query, collectionName){ 18 | const db = await this.connect(); 19 | const collection = db.collection(collectionName); 20 | let cursor = collection.find(query); 21 | return cursor; 22 | } 23 | 24 | async insert(data, collectionName){ 25 | const db = await this.connect(); 26 | 27 | const collection = await db.collection(collectionName); 28 | let result; 29 | if(data instanceof Array){ 30 | result = await collection.insertMany(data); 31 | } else { 32 | result = await collection.insertOne(data); 33 | } 34 | Logger.info(`Inserted ${result.insertedCount} records into collection ${collectionName}`); 35 | return result; 36 | } 37 | 38 | async update(query, data, collectionName){ 39 | const db = await this.connect(); 40 | 41 | const collection = db.collection(collectionName); 42 | let result = await collection.updateMany(query, {$set: data}); 43 | Logger.info(`Updated ${result.modifiedCount} objects in collection ${collectionName}`); 44 | return result; 45 | } 46 | 47 | async delete(query, collectionName){ 48 | const db = await this.connect(); 49 | 50 | const collection = db.collection(collectionName); 51 | let result = await collection.deleteMany(query); 52 | Logger.info(`Deleted ${result.deletedCount} objects in collection ${collectionName}`); 53 | 54 | return result; 55 | } 56 | 57 | //separate method for consistency 58 | async count(query, collectionName){ 59 | const db = await this.connect(); 60 | return await db.collection(collectionName).find(query).count(); 61 | } 62 | 63 | async connect(){ 64 | let db; 65 | if(CONNECTION_POOL[this.url]) { 66 | db = CONNECTION_POOL[this.url]; 67 | } else { 68 | db = await mongodb.MongoClient.connect(this.url); 69 | CONNECTION_POOL[this.url] = db; 70 | Logger.info(`Opened database connection ${this.url}`); 71 | } 72 | return db; 73 | } 74 | 75 | async disconnect(){ 76 | await CONNECTION_POOL[this.url].close(); 77 | delete CONNECTION_POOL[this.url]; 78 | Logger.ingo(`Closed db connection ${this.url}`); 79 | return this; 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /database/index.js: -------------------------------------------------------------------------------- 1 | exports.Database = require('./Database'); 2 | exports.MongoDatabase = require('./MongoDatabase'); 3 | exports.MariaDatabase = require('./MariaDatabase'); 4 | 5 | const mongo = new MongoDatabase(encodeURI(`mongodb://${process.env['MONGO_TEST_USER']}:${process.env['MONGO_TEST_PASS']}@${process.env['MONGO_TEST_HOST']}/test`)); 6 | const maria = new MariaDatabase(encodeURI(`mysql://${process.env['MARIA_TEST_USER']}:${process.env['MARIA_TEST_PASS']}@${process.env['MARIA_TEST_HOST']}:3306/test`)); 7 | 8 | //TODO: change in production - actually return database instance for appropriate environment 9 | exports.DEF_MONGO = mongo; 10 | exports.DEF_MARIA = maria; 11 | -------------------------------------------------------------------------------- /database/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "database", 3 | "version": "0.0.0", 4 | "description": "database classes for this app" 5 | } -------------------------------------------------------------------------------- /gulpfile.js: -------------------------------------------------------------------------------- 1 | const gulp = require('gulp'); 2 | const browserSync = require('browser-sync'); 3 | const webpackDevMiddleware = require('webpack-dev-middleware'); 4 | const webpackHotMiddleware = require('webpack-hot-middleware'); 5 | const path = require('path'); 6 | const { Logger, config, webpackConfig } = require('common'); 7 | const webpack = require('webpack')(webpackConfig); 8 | 9 | gulp.task('dev', function(){ 10 | browserSync({ 11 | server: { 12 | baseDir: [ path.join(config.appRoot, 'client') ], 13 | middleware: [ 14 | webpackDevMiddleware(webpack, { 15 | publicPath: webpackConfig.output.publicPath, 16 | stats: { colors: true } 17 | }), 18 | webpackHotMiddleware(webpack) 19 | ] 20 | }, 21 | files: [ 22 | config.appRoot + '/client/dist/css/' + '**/*.css', 23 | config.appRoot + '/client/dist/' + '**/*.html' 24 | ] 25 | }); 26 | }); -------------------------------------------------------------------------------- /jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2017", 4 | "module": "system" 5 | }, 6 | "include": [ 7 | "remove/this/section/for/IntelliSense/to/work", 8 | "middleware/**/*", 9 | "models/**/*", 10 | "database/**/*", 11 | "common/**/*", 12 | "client/scripts/**/*", 13 | "./*" 14 | ], 15 | "exclude": [ 16 | "node_modules", 17 | "client/dist/**/*" 18 | ] 19 | } -------------------------------------------------------------------------------- /middleware/authenticator.js: -------------------------------------------------------------------------------- 1 | const jwt = require('jsonwebtoken'); 2 | const { config } = require("common"); 3 | const { User } = require("models"); 4 | 5 | /** 6 | * This class can contain your authentication mechanisms 7 | * In controllers, when declaring routes simple use the notation 8 | * router.get('/mycontroller/action', Authenticator.login, MyController.action); 9 | */ 10 | class Autheticator { 11 | constructor() { 12 | throw new Error('Authenticator is a static abstract class'); //haters gonna gate 13 | } 14 | 15 | static async login(ctx, next) { 16 | //compare password 17 | switch (ctx.header.strategy) { 18 | case 'google': 19 | return ctx.redirect('/'); // new require('googleapis').auth.OAuth2(clientid, secret, 'mysite/login/callback').generateAuthUrl or something like that 20 | case 'facebook': 21 | return ctx.redirect('/'); // get the facebook auth redirect url 22 | case 'github': 23 | return ctx.redirect('/'); // redirect to github auth url 24 | case 'saml': 25 | return ctx.redirect('/'); // redirect to SAML provider url (need to register app url as trusted first and install shibboleth agent on server) 26 | default: 27 | if (ctx.request.body.password === 'password') { 28 | ctx.status = 200; 29 | let token = await User.find({ username: ctx.body.username }); 30 | ctx.json({ 31 | token: jwt.sign(token, config.keys.session), //Should be the same secret key as the one used is jwt configuration 32 | message: "Successfully logged in!" 33 | }); 34 | //done 35 | } else { 36 | ctx.status = ctx.status = 401; 37 | ctx.json({ 38 | message: "Authentication failed" 39 | }); 40 | } 41 | break; 42 | } 43 | } 44 | } 45 | 46 | module.exports = Autheticator; -------------------------------------------------------------------------------- /middleware/index.js: -------------------------------------------------------------------------------- 1 | exports.Authenticator = require('./authenticator'); 2 | exports.Responder = require('./responder'); -------------------------------------------------------------------------------- /middleware/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "middleware", 3 | "version": "0.0.0", 4 | "description": "Middleware for this application" 5 | } 6 | -------------------------------------------------------------------------------- /middleware/responder.js: -------------------------------------------------------------------------------- 1 | const pug = require('pug'); 2 | const path = require('path'); 3 | const ejs = require('ejs'); 4 | const fs = require('fs'); 5 | const { Readable } = require('stream'); 6 | 7 | const { config, Logger, Utils } = require('common'); 8 | 9 | class Responder { 10 | constructor(ctx){ 11 | this.views = path.join(config.appRoot, 'views'); 12 | //TODO: this is the first thing that sees ctx after new request is made, maybe set some flags? wantsJSON and such? 13 | Object.defineProperties(ctx,{ 14 | view: { 15 | value: this.view.bind(this), 16 | writable: false, 17 | configurable: false, 18 | enumerable: false 19 | }, 20 | json: { 21 | value: this.json.bind(this), 22 | writable: false, 23 | configurable: false, 24 | enumerable: false 25 | }, 26 | raw: { 27 | value: this.raw.bind(this), 28 | writable: false, 29 | configurable: false, 30 | enumerable: false 31 | }, 32 | websocket: { 33 | value: this.websocket.bind(this), 34 | writable: false, 35 | configurable: false, 36 | enumerable: false 37 | } 38 | }); 39 | } 40 | 41 | get pugOptions(){ 42 | const options = { 43 | Utils //expose util functions to the templates 44 | } 45 | if(this.viewToRender && config.env === 'production') options.cache = true; 46 | if(options.cache) options.filename = path.basename(this.viewToRender); 47 | return options; 48 | } 49 | 50 | get ejsOptions(){ 51 | const options = { 52 | locals: { 53 | Utils //expose util functions to the templates 54 | }, 55 | _with: false 56 | } 57 | if(this.viewToRender && config.env === 'production') options.cache = true; 58 | if(options.cache) options.filename = path.basename(this.viewToRender); 59 | return options; 60 | } 61 | 62 | view(view, data){ 63 | if(view.includes('/views')) 64 | this.viewToRender = path.relative(this.views, view); 65 | else 66 | this.viewToRender = view; 67 | this.data = data; 68 | } 69 | 70 | json(data){ 71 | this.data = data; 72 | } 73 | 74 | raw(stringBufferOrStream){ 75 | if(stringBufferOrStream instanceof Buffer || stringBufferOrStream instanceof Readable || typeof stringBufferOrStream === 'string') 76 | this.data = stringBufferOrStream; 77 | else Logger.error('Can not send raw response - not a buffer, string or readabale stream'); 78 | } 79 | 80 | websocket(){ 81 | throw new Error('Not implemented'); 82 | } 83 | 84 | render(ctx){ 85 | if(!this.data) this.data = {}; 86 | if(ctx.csrf) this.data.csrf = ctx.csrf; 87 | if(this.viewToRender){ 88 | const ext = path.extname(this.viewToRender); 89 | const view = path.join(this.views, this.viewToRender); 90 | switch(ext){ 91 | case '.pug': 92 | ctx.body = pug.renderFile(view, Object.assign({},this.data,this.pugOptions)) 93 | break; 94 | case '.ejs': 95 | ctx.body = ejs.render(view, this.data, this.ejsOptions); 96 | break; 97 | case '.html': 98 | Logger.warn(`Outputting raw HTML file, ${view}, please consider serving static files using a separate server`); 99 | ctx.body = fs.readFileSync(view, 'utf8'); 100 | break; 101 | } 102 | return; 103 | } 104 | if(this.data instanceof Buffer || this.data instanceof Readable || typeof this.data === 'string'){ 105 | if(this.data instanceof Readable) ctx.req.pipe(this.data); 106 | else ctx.body = this.data; 107 | return; 108 | } 109 | ctx.body = JSON.stringify(this.data); 110 | } 111 | 112 | static async middleware(ctx, next){ 113 | const responder = new Responder(ctx); 114 | try { 115 | //topmost try-catch block, this will catch ALL errors 116 | ctx.responder = responder; 117 | 118 | await next(); 119 | 120 | if(responder.viewToRender || responder.data) 121 | responder.render(ctx); 122 | //otherwise no methods were called, so we consider the request fully processed already 123 | //TODO: maybe log something? 124 | } catch(err){ 125 | Logger.error(`An error occurred processing request: ${err.message}`); 126 | Logger.error(err.stack); 127 | responder.view('error.pug', { error: err }) 128 | responder.render(ctx); 129 | } 130 | } 131 | } 132 | 133 | module.exports = Responder; 134 | -------------------------------------------------------------------------------- /migrations/nosql/20171020063709-create-comment-indexes.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | let dbm; 4 | let type; 5 | let seed; 6 | 7 | exports.setup = function(options, seedLink) { 8 | dbm = options.dbmigrate; 9 | type = dbm.dataType; 10 | seed = seedLink; 11 | }; 12 | 13 | exports.up = async function(db) { 14 | let collection = await db.createCollection('comments'); 15 | let indexName = await collection.createIndex('comments', 'userId', {background:true, w:1}); //create an index on the userId "foreign key" 16 | if (typeof indexName === 'undefined') return Promise.reject(new Error('Could not create an index on comment collection')); 17 | return Promise.resolve(indexName); 18 | } 19 | 20 | exports.down = function(db) { 21 | return db.collection('comments').dropIndex('userId'); 22 | }; 23 | 24 | exports._meta = { 25 | "version": 1 26 | }; -------------------------------------------------------------------------------- /migrations/sql/20171020063353-create-products.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | let dbm; 4 | let type; 5 | let seed; 6 | let fs = require('fs'); 7 | let path = require('path'); 8 | let Promise; 9 | 10 | exports.setup = function(options, seedLink) { 11 | dbm = options.dbmigrate; 12 | type = dbm.dataType; 13 | seed = seedLink; 14 | Promise = options.Promise; 15 | }; 16 | 17 | exports.up = function(db) { 18 | let filePath = path.join(__dirname, 'sqls', '20171020063353-create-products-up.sql'); 19 | return new Promise( function( resolve, reject ) { 20 | fs.readFile(filePath, {encoding: 'utf-8'}, function(err,data){ 21 | if (err) return reject(err); 22 | console.log('received data: ' + data); 23 | 24 | resolve(data); 25 | }); 26 | }) 27 | .then(function(data) { 28 | return db.runSql(data); 29 | }); 30 | }; 31 | 32 | exports.down = function(db) { 33 | let filePath = path.join(__dirname, 'sqls', '20171020063353-create-products-down.sql'); 34 | return new Promise( function( resolve, reject ) { 35 | fs.readFile(filePath, {encoding: 'utf-8'}, function(err,data){ 36 | if (err) return reject(err); 37 | console.log('received data: ' + data); 38 | 39 | resolve(data); 40 | }); 41 | }) 42 | .then(function(data) { 43 | return db.runSql(data); 44 | }); 45 | }; 46 | 47 | exports._meta = { 48 | "version": 1 49 | }; 50 | -------------------------------------------------------------------------------- /migrations/sql/20171020063500-create-warehouses.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | let dbm; 4 | let type; 5 | let seed; 6 | let fs = require('fs'); 7 | let path = require('path'); 8 | let Promise; 9 | 10 | exports.setup = function(options, seedLink) { 11 | dbm = options.dbmigrate; 12 | type = dbm.dataType; 13 | seed = seedLink; 14 | Promise = options.Promise; 15 | }; 16 | 17 | exports.up = function(db) { 18 | let filePath = path.join(__dirname, 'sqls', '20171020063500-create-warehouses-up.sql'); 19 | return new Promise( function( resolve, reject ) { 20 | fs.readFile(filePath, {encoding: 'utf-8'}, function(err,data){ 21 | if (err) return reject(err); 22 | console.log('received data: ' + data); 23 | 24 | resolve(data); 25 | }); 26 | }) 27 | .then(function(data) { 28 | return db.runSql(data); 29 | }); 30 | }; 31 | 32 | exports.down = function(db) { 33 | let filePath = path.join(__dirname, 'sqls', '20171020063500-create-warehouses-down.sql'); 34 | return new Promise( function( resolve, reject ) { 35 | fs.readFile(filePath, {encoding: 'utf-8'}, function(err,data){ 36 | if (err) return reject(err); 37 | console.log('received data: ' + data); 38 | 39 | resolve(data); 40 | }); 41 | }) 42 | .then(function(data) { 43 | return db.runSql(data); 44 | }); 45 | }; 46 | 47 | exports._meta = { 48 | "version": 1 49 | }; 50 | -------------------------------------------------------------------------------- /migrations/sql/20171020074014-create-join-product-warehouse.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | let dbm; 4 | let type; 5 | let seed; 6 | let fs = require('fs'); 7 | let path = require('path'); 8 | let Promise; 9 | 10 | exports.setup = function(options, seedLink) { 11 | dbm = options.dbmigrate; 12 | type = dbm.dataType; 13 | seed = seedLink; 14 | Promise = options.Promise; 15 | }; 16 | 17 | exports.up = function(db) { 18 | let filePath = path.join(__dirname, 'sqls', '20171020074014-create-join-products-warehouses-up.sql'); 19 | return new Promise( function( resolve, reject ) { 20 | fs.readFile(filePath, {encoding: 'utf-8'}, function(err,data){ 21 | if (err) return reject(err); 22 | console.log('received data: ' + data); 23 | 24 | resolve(data); 25 | }); 26 | }) 27 | .then(function(data) { 28 | return db.runSql(data); 29 | }); 30 | }; 31 | 32 | exports.down = function(db) { 33 | let filePath = path.join(__dirname, 'sqls', '20171020074014-create-join-products-warehouses-down.sql'); 34 | return new Promise( function( resolve, reject ) { 35 | fs.readFile(filePath, {encoding: 'utf-8'}, function(err,data){ 36 | if (err) return reject(err); 37 | console.log('received data: ' + data); 38 | 39 | resolve(data); 40 | }); 41 | }) 42 | .then(function(data) { 43 | return db.runSql(data); 44 | }); 45 | }; 46 | 47 | exports._meta = { 48 | "version": 1 49 | }; 50 | -------------------------------------------------------------------------------- /migrations/sql/sqls/20171020063353-create-products-down.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE products; -------------------------------------------------------------------------------- /migrations/sql/sqls/20171020063353-create-products-up.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE products( 2 | id INT UNSIGNED NOT NULL AUTO_INCREMENT, 3 | name VARCHAR(255) NOT NULL, 4 | # Must set double precision manually, otherwise direct comparison with javascript numbers will not work due to float precision difference: 5 | # js 4.34, mdb 4.340005323211.... This does not matter when using prepared queries over binary protocol because the number will typecast to 6 | # database equivalent anyway, just like on insert, but when the number is string-encoded, database will compare it exactly as passed: 7 | # 4.34000000000000 = 4.340005323211.... which is of course not the case. 3 is a safe bet, since the database seems to not care about low precision like this 8 | # this is of course using mysql2/mysql-node. Native client doesn't have this problem, you just use FLOAT there. 9 | price DOUBLE(8,3) NOT NULL, 10 | description TEXT, 11 | UNIQUE INDEX id (id), 12 | PRIMARY KEY (id) 13 | ) 14 | CHARACTER SET 'utf8' 15 | #enables canse insensitive search on text fields using LIKE 16 | COLLATE 'utf8_general_ci'; -------------------------------------------------------------------------------- /migrations/sql/sqls/20171020063500-create-warehouses-down.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE warehouses; -------------------------------------------------------------------------------- /migrations/sql/sqls/20171020063500-create-warehouses-up.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE warehouses( 2 | id INT UNSIGNED NOT NULL AUTO_INCREMENT, 3 | address VARCHAR(800) NOT NULL, 4 | info TEXT, 5 | UNIQUE INDEX id (id), 6 | PRIMARY KEY (id) 7 | ) 8 | CHARACTER SET 'utf8' 9 | #enables canse insensitive search on text fields using LIKE 10 | COLLATE 'utf8_general_ci'; -------------------------------------------------------------------------------- /migrations/sql/sqls/20171020074014-create-join-products-warehouses-down.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE join_products_warehouses -------------------------------------------------------------------------------- /migrations/sql/sqls/20171020074014-create-join-products-warehouses-up.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE join_products_warehouses( 2 | id INT UNSIGNED NOT NULL AUTO_INCREMENT, 3 | product_id INT UNSIGNED NOT NULL, 4 | warehouse_id INT UNSIGNED NOT NULL, 5 | quantity INT UNSIGNED DEFAULT 0, 6 | CONSTRAINT `fk_product_id` 7 | FOREIGN KEY (`product_id`) REFERENCES `products` (`id`) 8 | ON DELETE CASCADE 9 | ON UPDATE CASCADE, 10 | CONSTRAINT `fk_warehouse_id` 11 | FOREIGN KEY (`warehouse_id`) 12 | REFERENCES `warehouses` (`id`) 13 | ON DELETE CASCADE 14 | ON UPDATE CASCADE, 15 | INDEX `i_product_id` (`product_id`), 16 | INDEX `i_warehouse_id` (`warehouse_id`), 17 | PRIMARY KEY (`id`) 18 | ); -------------------------------------------------------------------------------- /models/Comment.js: -------------------------------------------------------------------------------- 1 | const mongodb = require('mongodb'); 2 | const MongoModel = require('./MongoModel'); 3 | const { Logger, Utils, config } = require('common'); 4 | const { MongoDatabase, DEF_MONGO } = require('database'); 5 | 6 | let collectionName = 'comments'; 7 | let db = DEF_MONGO; 8 | 9 | class Comment extends MongoModel { 10 | constructor(data) { 11 | super(data); 12 | this.likes = 0; 13 | this.deserialize(data); 14 | } 15 | 16 | get user() { 17 | return User.find(this.userId); 18 | } 19 | 20 | static set DB(newdb) { 21 | if (newdb instanceof MongoDatabase) { 22 | Logger.warn(`Warning! Switching database for ${Utils.getObjectClassName(this)}! All records from now on will operate with ${newdb.url}`); 23 | db = newdb; 24 | } else { 25 | throw new TypeError(`This model only supports MongoDatabase type, was ${newdb.constructor.name}`); 26 | } 27 | } 28 | 29 | static get DB() { 30 | return db; 31 | } 32 | 33 | static get DATASTORE() { 34 | return collectionName; 35 | } 36 | 37 | get db() { 38 | return Comment.DB; 39 | } 40 | 41 | get datastore() { 42 | return Comment.DATASTORE; 43 | } 44 | } 45 | 46 | exports.model = Comment; -------------------------------------------------------------------------------- /models/MariaModel.js: -------------------------------------------------------------------------------- 1 | const Model = require('./Model'); 2 | const { MariaDatabase } = require('database'); 3 | const { Utils, Logger } = require('common'); 4 | 5 | module.exports = 6 | class MariaModel extends Model { 7 | constructor(data){ 8 | super(data); 9 | } 10 | 11 | static parseWhere(where, params){ 12 | if(!where) return []; 13 | if(Array.isArray(where) && where.every(item=>Number.isInteger(item))) return [ 'id IN (?)', where ]; 14 | if(Utils.legitObject(where)) return [ where ]; 15 | if(typeof where === 'string') return [ where, ...params ]; 16 | if(Number.isInteger(where)) return [ `id = ${where}` ]; 17 | throw new TypeError('Where parameter was not an array, plain object, string or integer'); 18 | } 19 | 20 | //id, where clause or object 21 | static async find(where, ...params){ 22 | let [data] = await this.DB.select(this.DATASTORE, ...MariaModel.parseWhere(where, params)); 23 | if(data.length>1) { 24 | Logger.warn(`Criteria passed to find matched unexpected amount of records (expected 1, matched ${data.length}, only the first record will be returned`); 25 | } 26 | return new this(data[0]); 27 | } 28 | 29 | static async where(where, ...params){ 30 | let [data] = await this.DB.select(this.DATASTORE, ...MariaModel.parseWhere(where, params)); 31 | return data.map(dd=>new this(dd)); 32 | } 33 | 34 | static async delete(where, ...params){ 35 | let result = await this.DB.delete(this.DATASTORE, ...MariaModel.parseWhere(where, params)); 36 | return result[0].affectedRows; 37 | } 38 | 39 | static async count(where, ...params){ 40 | let result = await this.DB.count(this.DATASTORE, ...MariaModel.parseWhere(where, params)); 41 | return result[0].count; 42 | } 43 | 44 | static async insert(...data){ 45 | //make sure they can all be instantiated, since we don't wanna make a new query just to verify 46 | const products = []; 47 | data = Utils.flatten(data); 48 | data.forEach(item=>products.push(new this(item))); 49 | let inserted = await this.DB.insert(this.DATASTORE, ...data); 50 | for(let i = 0; iUtils.legitObject(item) && item.id)){ 61 | throw new Error('To update multiple records, every update record data must contain the id attribute equal to that of the database row to match against'); 62 | } 63 | result = await this.DB.updateMultiple(this.DATASTORE, data, (query, item, index)=>query.where(`id = ${item.id}`)); 64 | } else { 65 | result = await this.DB.update(this.DATASTORE, data, ...MariaModel.parseWhere(where, params)); 66 | } 67 | return result; 68 | } 69 | 70 | static async query(sqlquery){ 71 | if(!(sqlquery instanceof MariaDatabase.SQLQuery)) throw new Error('The raw query must be an instance of MariaDatabase.SQLQuery'); 72 | return await this.DB.execute(sqlquery); 73 | } 74 | 75 | //If you want to run these in a transaction, you can simply allow an override of the database through a parameter 76 | //then pass the transaction database instance to the method when you need to. 77 | async save(){ 78 | if(this.id){ 79 | let result = await this.db.update(this.datastore, this.serialize(), `id = ${this.id}`); 80 | } else { 81 | //only get the first query result, we don't expect more 82 | let [inserted] = await this.db.insert(this.datastore, this.serialize()); 83 | this.id = inserted.insertId; 84 | } 85 | return this; 86 | } 87 | 88 | async delete(){ 89 | if(!this.id){ 90 | Logger.warn('No id on Product, calling delete() is redundant'); 91 | return this; 92 | } 93 | let result = await this.db.delete(this.datastore, `id = ${this.id}`); 94 | if(result.affectedRows !== 1) Logger.error(`Something went wrong during delete - expected affected rows to be 1, but got ${result.affectedRows}`); 95 | return this; 96 | } 97 | 98 | async get(){ 99 | let result; 100 | //TODO: implementation of this method might vary quite severely. 101 | //This is just an example of how it *could* be done - either by id or by combination of all available attributes 102 | if(!this.id){ 103 | result = await this.db.select(this.datastore, this.serialize()); 104 | if(result[0].length > 1) { 105 | Logger.error(`Tried to loose match a record ${this}, but the database returned more than 1 result. Ignoring.`) 106 | return this; 107 | } 108 | } else { 109 | result = await this.db.select(this.datastore, 'id = ?', this.id); 110 | } 111 | let [data] = result; 112 | if(data.length === 0) { 113 | Logger.error(`Did not find record ${this} in the database.`); 114 | } else { 115 | this.deserialize(data[0]); 116 | } 117 | return this; 118 | } 119 | } -------------------------------------------------------------------------------- /models/Model.js: -------------------------------------------------------------------------------- 1 | const { Utils } = require('common'); 2 | 3 | const SYMBOL_KEY = 'modulesLoadedSymbolKey'; 4 | /** 5 | * Abstract class to be overridden by other models. 6 | * If you want to use an ORM like sequelize or Mongoose you probably don't need to even extend this (I still highly recommend to to keep 7 | * record-related logic in their own classes). If you only use one type of DB and use an ORM, simply replace the Errors here with the corresponding 8 | * ORM methods. 9 | * 10 | * The recommended approach however is to extend this Model anyway and place the ORM methods to Database class. 11 | */ 12 | module.exports = class Model { 13 | constructor(data){ 14 | if(!data) throw new Error('A model can not be constructed with no data, please provide at least something'); 15 | if(!global[Model.MODELS_LOADED_FLAG]){ 16 | throw new Error(`Models have not been loaded, please call require('models') once before executing any model code`); 17 | } 18 | //TODO: return proxy instead like in Association, to validate values on assignment 19 | //return new Proxy(this, validator); //where validator is Child.VALIDATOR implementing Proxy.handler passed via constructor parameter 20 | } 21 | 22 | /** 23 | * Returns a symbol indicating that models have been loaded. Must be set to global object once that happens. 24 | */ 25 | static get MODELS_LOADED_FLAG(){ 26 | return Symbol.for(SYMBOL_KEY); 27 | } 28 | 29 | /** 30 | * Obtain a reference to the Model's database object. This should return a Database wrapper 31 | * which in turn maintains a pool of actual driver connections (or your ORM) 32 | * @returns {Database} database instance for the model 33 | */ 34 | static get DB(){ 35 | throw new Error(`DB getter is abstract and must be implemented by subclasses`); 36 | } 37 | /** 38 | * Change a model's database connection. Not all models need to support this. Useful for testing and scaling. 39 | * @param {Database} newdb Database that all database records will use from the moment of invoking this function 40 | */ 41 | static set DB(newdb){ 42 | throw new Error(`${Utils.getObjectClassName(this)} does not support switching databases`); 43 | } 44 | 45 | /** 46 | * Returns some identity of the datastore (table, collection, etc.) that your model uses in it's query methods 47 | * Typically this is your ORM's representation of a table or collection 48 | * e.g. result of sequelize.define('tableName', { shema }) or mongoose.model({ schema }) 49 | * or, if not using an ORM, the name of a table or a collection, as a string 50 | * 51 | * Recommended approach is to define this in your Model module as a "private" variable/field so it gets executed 52 | * once when the server starts and have your model's implementation of this method return a reference to that variable. 53 | * @returns {*} identity of the datastore 54 | */ 55 | static get DATASTORE(){ 56 | throw new Error(`Model.DATASTORE getter is abstract and must be implemented by subclasses`) 57 | } 58 | 59 | /** 60 | * Sets the datastore identity for your model (used to change table/collection name for all future operations). 61 | * @param {*} newDatastore the new datastore identity your model will use from the time of setting this static variable 62 | */ 63 | static set DATASTORE(newDatastore){ 64 | throw new Error(`Model.DATASTORE setter is abstract and must be implemented by subclasses`) 65 | } 66 | 67 | // Allows parent logic to use child class's database instance, if returned from this method 68 | // via ChildModel.DB 69 | static get db(){ 70 | throw new Error(`Model.db getter is abstract and must be implemented by subclasses`) 71 | } 72 | // Allows parent logic to use child class's datastore, if returned from this method 73 | // via ChildModel.datastore 74 | static get datastore(){ 75 | throw new Error(`Model.datastore getter is abstract and must be implemented by subclasses`) 76 | } 77 | 78 | /** 79 | * Returns the number of records of this model in the currently connected database 80 | * @returns {Number} number of records in this database 81 | */ 82 | static async count(){ 83 | throw new Error('Model.count is abstract and must be implemented by subclasses'); 84 | } 85 | 86 | /** 87 | * Find a single record with the provided query. Subclasses determine which parameters this accepts. 88 | * @returns {Model} instance of subclass 89 | */ 90 | static async find(){ 91 | throw new Error('Model.find is abstract and must be implemented by subclasses'); 92 | } 93 | 94 | /** 95 | * Find all arrays matching provided query. Subclasses determine which parameters this accepts. 96 | * @returns {[Model]} Array of subclass instances 97 | */ 98 | static async where() { 99 | throw new Error('Model.where is abstract and must be implemented by subclasses'); 100 | } 101 | 102 | /** 103 | * Delete all records matching the provided query. Subclasses determine which parameters this accepts. 104 | * @returns {Number} amount of records deleted 105 | */ 106 | static async delete(){ 107 | throw new Error('Static Model.delete is abstract and must be implemented by subclasses'); 108 | } 109 | 110 | /** 111 | * Updates all records matching the provided query. Subclasses determine which parameters this accepts. 112 | * @returns {Number} amount of records modified 113 | */ 114 | static async update(){ 115 | throw new Error('Model.update is abstract and must be implemented by subclasses'); 116 | } 117 | 118 | /** 119 | * Updates the current record instance with information from the database 120 | * @returns {Model} the current instance, updated with the latest information, if available 121 | */ 122 | async get(){ 123 | throw new Error('Model.get is abstract and must be implemented by subclasses'); 124 | } 125 | 126 | /** 127 | * Updates the database with the current instance's values or inserts a new record matching current instance. 128 | * @returns {Model} the current instance, updated with any database-generated values 129 | */ 130 | async save(){ 131 | throw new Error('Model.save is abstract and must be implemented by subclasses'); 132 | } 133 | 134 | /** 135 | * Deletes the current instance's corresponding record in the database (if any) 136 | * @returns {Number} amount of records deleted (should be 1 or 0) 137 | */ 138 | async delete(){ 139 | throw new Error('Model.delete is abstract and must be implemented by subclasses'); 140 | } 141 | 142 | /** 143 | * Retreives reference to the model instance's database object. Best practice is to return 144 | * Model.DB here 145 | * @returns {Database} database reference that this instance is using 146 | */ 147 | get db(){ 148 | throw new Error(`Model.db getter is abstract and must be implemented by subclasses`); 149 | } 150 | 151 | /** 152 | * Parses data returned from the Database instance of the model into the model instance properties 153 | * @param {JSON} data some data from the Database object to parse into the model's properties 154 | * @returns {Model} current instance, updated with the parsed properties 155 | */ 156 | deserialize(data){ 157 | this.id = data.id; //default ID handling, will be overridden by most subclasses, I suppose 158 | this.createdAt = data.createdAt; 159 | this.updatedAt = data.updatedAt; 160 | //TODO: return new Proxy(this, this.validator()); 161 | return this; 162 | } 163 | 164 | /* 165 | TODO: Use a Proxy for natively verifying model values (simply return the proxy from constructors of Models) 166 | /** 167 | * Returns a proxy handler object for this instance, that validates values immediately at the time of assignment. 168 | * Can be overridden by subclasses to provide custom model validation(e.g. against a schema). Default implementation prohibits dynamically 169 | * adding functions to model instances as well as other models directly without using Association class 170 | */ 171 | /* 172 | validator(){ 173 | return { 174 | set(target, property, value, receiver){ 175 | if(typeof value === 'function') throw new Error('Models do not support dynamic function properties'); 176 | target[property] = value; 177 | return true; 178 | }, 179 | get(target, property, receiver){ 180 | return target[property]; 181 | } 182 | } 183 | } 184 | */ 185 | 186 | /** 187 | * Serializes current instance's enumerable properties into an object understood by the Database instance. 188 | * Subclasses determine which parameters this accepts. 189 | * @returns {JSON} json object containing current instance's data to be put into database. 190 | */ 191 | serialize(){ 192 | //default timestamps. For mariadb you can 193 | //omit calling the super function, if you use 194 | //TIMESTAMP as default value for updatedAt field and 195 | //a macro for createdAt 196 | if(!this.createdAt){ 197 | this.createdAt = new Date(); 198 | } 199 | return { 200 | createdAt: this.createdAt, 201 | updatedAt: new Date() 202 | } 203 | } 204 | 205 | /** 206 | * Overrides default behaviour of Model instances when calling JSON.stringify(instance) on them 207 | * @returns {JSON} the JSON object to be strigified 208 | */ 209 | toJSON(){ 210 | return this.serialize(); 211 | } 212 | 213 | [Symbol.toPrimitive](hint){ 214 | if(hint !== 'number'){ 215 | //Model: id = 1, field1 = someValue, field2 = another, okidoki = 1... 216 | return `${Utils.getObjectClassName(this)}: ${Object.keys(this).map(k=>`${k} = ${this[k]}`).join(', ').substring(0, 60)} ...`; 217 | } 218 | throw new TypeError(`${Utils.getObjectClassName(this)} can not be cast to number`); 219 | } 220 | } -------------------------------------------------------------------------------- /models/MongoModel.js: -------------------------------------------------------------------------------- 1 | const mongodb = require('mongodb') 2 | const Model = require('./Model') 3 | const { Logger, Utils } = require('common'); 4 | 5 | module.exports = class MongoModel extends Model { 6 | constructor(data){ 7 | super(data); 8 | //since this is first line, if db is something other than a Database instance, it should crash right away 9 | try { 10 | if(data._id) this.id = data._id.toString(); 11 | else if(data.id) this.id = data.id; 12 | else if (typeof data === 'string') this.id = data; 13 | } catch (err){ 14 | Logger.error(`Error assigning an ID to Mongo model wih collection name ${this.datastore}, data provided: \n ${JSON.stringify(data, 2)}`); 15 | } 16 | } 17 | 18 | /** 19 | * Transform the query object and fix some common problems like misspelling _id for id and so on. 20 | * If the query is an array, will recursively apply this function to every element 21 | * @param {JSON} query the query object 22 | * @returns {JSON} the transformed query that's hopefully sane enough to not completely freak out the driver... 23 | */ 24 | static transformQuery(query){ 25 | if(!query) return {}; 26 | if(typeof query === 'string') return { _id: new mongodb.ObjectId(query) }; 27 | if(query.id && !query._id && typeof query.id === 'string') { 28 | Logger.warn('Incorrect mongodb query format detected - replacing query.id with query._id'); 29 | query._id = new mongodb.ObjectId(query.id); 30 | delete query.id; 31 | } 32 | if(query instanceof Array){ 33 | if(query.every(qq=>typeof qq === 'string')) { 34 | return { 35 | _id: { 36 | $in: query.map(qq=>new mongodb.ObjectId(qq)) 37 | } 38 | }; 39 | } 40 | } 41 | return query; 42 | } 43 | 44 | static async count(query, db, collection) { 45 | //lets us use the child's overridden getters, arguments, which allows the child to avoid implementing any of the common functions 46 | //unless they want special behaviour 47 | db = db || this.DB; 48 | collection = collection || this.DATASTORE; 49 | 50 | query = MongoModel.transformQuery(query); 51 | 52 | return await db.count(query, collection); 53 | } 54 | 55 | static async find(query, db, collection){ 56 | db = db || this.DB; 57 | collection = collection || this.DATASTORE; 58 | 59 | query = MongoModel.transformQuery(query); 60 | const cursor = await db.select(query, collection); 61 | const record = await cursor.next(); 62 | //TODO: throw a 404 not found error - decide yourself how this should look 63 | return new this(record); 64 | } 65 | 66 | static async where(query, db, collection, returnCursor) { 67 | db = db || this.DB; 68 | collection = collection || this.DATASTORE; 69 | 70 | //transform query for this model 71 | query = MongoModel.transformQuery(query); 72 | const cursor = await db.select(query, collection); 73 | if(!returnCursor) return (await cursor.toArray()).map(r=>new this(r)); 74 | else return cursor; 75 | } 76 | 77 | static async delete(query, db, collection){ 78 | db = db || this.DB; 79 | collection = collection || this.DATASTORE; 80 | 81 | query = MongoModel.transformQuery(query); 82 | const result = await db.delete(query, collection); 83 | return result.deletedCount; 84 | } 85 | 86 | static async update(query, data, db, collection){ 87 | db = db || this.DB; 88 | collection = collection || this.DATASTORE; 89 | 90 | query = MongoModel.transformQuery(query); 91 | const result = await db.update(query, data, collection); 92 | return result.modifiedCount; 93 | } 94 | 95 | static async insert(data, db, collection){ 96 | db = db || this.DB; 97 | collection = collection || this.DATASTORE; 98 | 99 | const result = await db.insert(data, collection); 100 | return result.ops.map(r=>new this(r)); 101 | } 102 | 103 | async get(){ 104 | const cursor = await this.db.select((this.id) ? new mongodb.ObjectId(this.id) : this.serialize(), this.datastore); 105 | const record = await cursor.next(); 106 | if(record === null) throw new Error(`get() called on instance of ${Utils.getObjectClassName(this)}, but nothing was found, this record must have MongoDB id or other primary key set, was ${this.id}`); 107 | this.deserialize(record); 108 | return this; 109 | } 110 | 111 | async save(){ 112 | const serialized = this.serialize(); 113 | let inserted = false; 114 | //could use mongo usert op here, but for sake of consistency across the board, we don't. Feel free to change that, that's why this is not a framework 115 | if(serialized._id){ 116 | await MongoModel.update({_id: serialized._id}, serialized, this.db, this.datastore); 117 | } else { 118 | let insertResult = await this.db.insert(serialized, this.datastore); 119 | if(!insertResult) throw new Error(`MongoDatabase insert failed - no insertResult`); 120 | if(insertResult.ops.length === 0) Logger.warn(`Called save() on a new record, but no records were inserted!`); 121 | inserted = insertResult !== null && insertResult.ops[0]; 122 | this.id = insertResult.ops[0]._id.toString(); 123 | } 124 | return inserted; 125 | } 126 | 127 | async delete(){ 128 | const results = await MongoModel.delete(this.id, this.db,this.datastore); 129 | return results.deletedCount; 130 | } 131 | 132 | deserialize(data){ 133 | super.deserialize(data); 134 | for (let [key, value] of Object.entries(data)) { 135 | if(this[key]) continue; //don't deserialize if it's already been processed 136 | switch(key){ 137 | case '_id': 138 | this.id = value.toString(); 139 | break; 140 | //createdAt and updatedAt should be deserialized in superclass 141 | default: 142 | if(typeof value !== 'function'){ 143 | //you might wanna do more checks here, e.g. add Model.validate(key) method and call here 144 | this[key] = value; 145 | } 146 | break; 147 | } 148 | } 149 | } 150 | 151 | serialize(){ 152 | const data = super.serialize(); 153 | for (let [key, value] of Object.entries(this)) { 154 | if(data[key]) continue; //dont serialize if its already been serialized 155 | if(typeof value === 'undefined') continue; //don't serialize undefined fields 156 | if(typeof value === 'object'){ 157 | if(value instanceof Collection) continue; //don't serialize collections 158 | if(value instanceof Model && value.id){ 159 | data[key] = value.id; 160 | continue; 161 | } 162 | } 163 | switch(key){ 164 | case 'id': 165 | data._id = new mongodb.ObjectId(value); 166 | break; 167 | default: 168 | switch(typeof value){ 169 | //for any 'unknown' attributes serialize them if they are one of the 170 | //standard primitive types 171 | case 'string': 172 | case 'number': 173 | case 'boolean': 174 | case 'null': 175 | case 'undefined': 176 | data[key] = value; 177 | break; 178 | default: 179 | try { 180 | //if the value is not primitive, then do that trick with the JSON.stringify 181 | JSON.stringify(value); //throws if invalid 182 | data[key] = value; 183 | } catch(e) { 184 | Logger.error(`Could not serialize field ${key} - ${e.message}. The field value will not be saved!`); 185 | } 186 | break; 187 | } 188 | break; 189 | } 190 | } 191 | return data; 192 | } 193 | } -------------------------------------------------------------------------------- /models/Product.js: -------------------------------------------------------------------------------- 1 | const MariaModel = require('./MariaModel'); 2 | const { MariaDatabase, DEF_MARIA } = require('database'); 3 | const { Utils, Logger, config } = require('common'); 4 | 5 | let db = DEF_MARIA; 6 | let tableName = 'products'; 7 | 8 | class Product extends MariaModel { 9 | constructor(data){ 10 | super(data); 11 | this.deserialize(data); 12 | } 13 | 14 | get warehouses(){ 15 | const query = new MariaDatabase.SQLQuery('join_products_warehouses') 16 | .join('INNER JOIN', Warehouse.DATASTORE, 'warehouse_id', 'id') 17 | .where(`product_id = ?`) 18 | .values(this.id); 19 | return Product.DB.connect() 20 | .then(connection=>connection.query(...query.prepare())) 21 | .then(results=>results[0].map(data=>new Warehouse(data[Warehouse.DATASTORE]))); 22 | } 23 | 24 | deserialize(data){ 25 | if(data.id && !isNaN(data.id)) this.id = data.id; 26 | for(let [key, value] of Utils.iterateObject(data)){ 27 | switch(key){ 28 | case 'name': 29 | this.name = value; 30 | break; 31 | case 'price': 32 | this.price = Number.parseFloat(value); 33 | break; 34 | case 'description': 35 | this.description = value; 36 | break; 37 | //could probably throw on encountering unknown fields, but eh... 38 | } 39 | } 40 | return this; 41 | } 42 | 43 | serialize(id){ 44 | const json = { 45 | name: this.name, 46 | price: this.price, 47 | description: this.description 48 | } 49 | if(id) json.id = this.id; 50 | return json; 51 | } 52 | 53 | static set DB(newdb) { 54 | if (newdb instanceof MariaDatabase) { 55 | Logger.warn(`Warning! Switching database for ${Utils.getObjectClassName(this)}! All records from now on will operate with ${newdb.url}`); 56 | db = newdb; 57 | } else { 58 | throw new TypeError(`This model only supports MariaDatabase type, was ${newdb.constructor.name}`); 59 | } 60 | } 61 | 62 | /** 63 | * @returns {MariaDatabase} database instance used by this model 64 | */ 65 | static get DB() { 66 | return db; 67 | } 68 | 69 | static get DATASTORE() { 70 | return tableName; 71 | } 72 | 73 | get db() { 74 | return Product.DB; 75 | } 76 | 77 | get datastore() { 78 | return Product.DATASTORE; 79 | } 80 | } 81 | 82 | exports.model = Product; -------------------------------------------------------------------------------- /models/User.js: -------------------------------------------------------------------------------- 1 | const mongodb = require('mongodb'); 2 | const MongoModel = require('./MongoModel'); 3 | const { MongoDatabase, DEF_MONGO } = require('database'); 4 | const { Utils, Logger, config } = require('common'); 5 | 6 | let db = DEF_MONGO; 7 | let collectionName = 'users'; 8 | 9 | class User extends MongoModel { 10 | constructor(data) { 11 | super(data); 12 | this.deserialize(data); 13 | } 14 | 15 | deserialize(data) { 16 | if (typeof data === 'string') return; 17 | super.deserialize(data); //use parent's logic to set other attributes 18 | } 19 | 20 | get comments() { 21 | return Comment.where({ userId: this.id }); 22 | } 23 | 24 | async addComments(...texts) { 25 | let comments = await Comment.insert(texts.map(text => ({ text: text, likes: 0, userId: this.id }))); 26 | return comments; 27 | } 28 | 29 | static set DB(newdb) { 30 | if (newdb instanceof MongoDatabase) { 31 | Logger.warn(`Warning! Switching database for ${Utils.getObjectClassName(this)}! All records from now on will operate with ${newdb.url}`); 32 | db = newdb; 33 | } else { 34 | throw new TypeError(`This model only supports MongoDatabase type, was ${newdb.constructor.name}`); 35 | } 36 | } 37 | 38 | static get DB() { 39 | return db; 40 | } 41 | 42 | static get DATASTORE() { 43 | return collectionName; 44 | } 45 | 46 | get db() { 47 | return User.DB; 48 | } 49 | 50 | get datastore() { 51 | return User.DATASTORE; 52 | } 53 | } 54 | 55 | exports.model = User; -------------------------------------------------------------------------------- /models/Warehouse.js: -------------------------------------------------------------------------------- 1 | const MariaModel = require('./MariaModel'); 2 | const { MariaDatabase, DEF_MARIA } = require('database'); 3 | const { Utils, Logger, config } = require('common'); 4 | 5 | let db = DEF_MARIA; 6 | let tableName = 'warehouses'; 7 | 8 | class Warehouse extends MariaModel { 9 | constructor(data){ 10 | super(data); 11 | this.deserialize(data); 12 | } 13 | 14 | get products() { 15 | let query = new MariaDatabase.SQLQuery('join_products_warehouses'); 16 | query.join('INNER JOIN', 'products', 'product_id', 'id').where(`warehouse_id = ${this.id}`); 17 | return this.db.connect().then(connection=>connection.query(query.toString())) 18 | .then(results=>results[0].map(data=>new Product(data))); 19 | } 20 | 21 | async quantity(product){ 22 | let query = new MariaDatabase.SQLQuery('join_products_warehouses'); 23 | if(product instanceof Product) query.where(`warehouse_id = ${this.id} AND product_id = ${product.id}`); 24 | else if(typeof product === 'string') query.where(`warehouse_id = ${this.id}`).join('INNER JOIN', 'products', product); 25 | else if(typeof product === 'number') query.where(`warehouse_id = ${this.id} AND product_id = ${product}`); 26 | let connection = await this.db.connect(); 27 | let [data] = await connection.query(query.toString()); 28 | connection.release(); 29 | let total = 0; 30 | for(let row of data){ 31 | total += row.quantity; //should ideally not have more than one join entry per product/warehouse pair, but we didn't set up a composite index in migrations 32 | } 33 | return total; 34 | } 35 | 36 | /** 37 | * Adds products to the warehouse, creating new products if they don't exist. Creates join records in a transaction. 38 | * @param {Number|Function} quantity set or increase the quantity of the products by this value (or value returned by this parameter if its a function accepting product data) 39 | * @param {Product} products products to add or increase quantity of 40 | * @returns {Array} result of the transaction where first element is the result of transaction containing information on rows added or updated(an array), and second is an error if any occurred and transaction was rolled back 41 | */ 42 | async add(quantity, ...products){ 43 | //This is supposed to be an advanced example of functionality possible with the current APIs 44 | if(!quantity) throw new Error('Please specify quantity for products'); 45 | if(!this.id) throw new Error('id property missing on the warehouse. It must be present in the database before adding products to it.'); 46 | if(typeof quantity !== 'function' && typeof quantity !== 'number') throw new Error('Quantity must be a number applied to all products or a function that returns quantity for product data passed to it'); 47 | if(typeof quantity === 'function' && typeof quantity(products[0]) !== 'number') throw new Error('Quantity was a function, but did not return a number during test run, instead returned '+quantity(products[0])); 48 | let save = []; 49 | for(let product of products){ 50 | if(!(product instanceof Product)) { 51 | Logger.warn(`Entry ${product} was not an instance of Product. Please instantiate before adding, so that the data can be validated. Skipping now`); 52 | continue; 53 | } 54 | if (!product.id) save.push(product.save()); 55 | } 56 | await Promise.all(save); 57 | const thisId = this.id; 58 | let transaction = await this.db.transaction(async function(db, connection){ 59 | const joinTable = 'join_products_warehouses'; 60 | const productIds = products.map(p=>p.id); 61 | let existing = []; 62 | if(productIds.length > 0) 63 | [existing] = await db.select(joinTable, `product_id IN (?) AND warehouse_id = ?`, products.map(p=>p.id), thisId); 64 | const inserts = products.filter(p=>!existing.some(r=>r.product_id===p.id)).map(product=>({ 65 | product_id: product.id, 66 | warehouse_id: thisId, 67 | quantity: typeof quantity === 'function' ? quantity(product) : quantity 68 | })); 69 | let results = await db.insert(joinTable, inserts); 70 | if(existing.length>0){ 71 | if(typeof quantity !== 'function' || typeof quantity(existing[0]) !== 'number') Logger.error(`${existing.length} records already exist in the database, but the passed in quantity function can not process raw join records. Please update these records manually.`) 72 | else { 73 | //updating some records instead of inserting new ones 74 | const updates = existing.map(r=>({quantity: typeof quantity === 'function' ? quantity(r) : quantity})); 75 | let updateResults = await db.updateMultiple(joinTable, updates, (query, record, index)=>query.where(`id = ${existing[index].id}`)); 76 | results.push(...updateResults); 77 | } 78 | } 79 | return results; 80 | }); 81 | return transaction; 82 | } 83 | 84 | async purchase(product, quantity){ 85 | if(isNaN(quantity)) throw new Error('Quantity must be a number, was '+quantity); 86 | if(!(product instanceof Product)) throw new Error('Product must be an instance of the Product class to be purchased'); 87 | if(!product.id) throw new Error('Product instance must have an id to be purchased'); 88 | const self = this; 89 | let [data] = await this.db.select('join_products_warehouses', 'product_id = ? AND warehouse_id = ?', product.id, this.id); 90 | if(data.length === 0) throw new Error(`Product ${product.name} does not exist at ${this.address}`); 91 | if(data.length > 1) Logger.warn('Found more than 1 relation between product and warehouse, not ideal!'); 92 | let relation = data[0]; 93 | if(relation.quantity < quantity) throw new Error(`Not enough of ${product.name} at ${this.address} - requested ${quantity} but found only ${relation.quantity}`); 94 | const transaction = await this.db.transaction(async function(db, connection){ 95 | let result; 96 | if(relation.quantity === quantity){ 97 | result = await db.delete('id = ?', relation.id); 98 | } else { 99 | result = db.update('join_products_warehouses', { quantity: relation.quantity - quantity }, 'id = ?', relation.id) 100 | } 101 | return result; 102 | }); 103 | return transaction; 104 | } 105 | 106 | deserialize(data){ 107 | if(data.id) this.id = data.id; 108 | for(let [key, value] of Utils.iterateObject(data)){ 109 | switch(key){ 110 | case 'address': 111 | case 'info': 112 | this[key] = value; 113 | break; 114 | } 115 | } 116 | } 117 | 118 | serialize(id){ 119 | const json = { 120 | address: this.address, 121 | info: this.info, 122 | quantity: this.quantity 123 | } 124 | if(id) json.id = this.id; 125 | return json; 126 | } 127 | 128 | static set DB(newdb) { 129 | if (newdb instanceof MariaDatabase) { 130 | Logger.warn(`Warning! Switching database for ${Utils.getObjectClassName(this)}! All records from now on will operate with ${newdb.url}`); 131 | db = newdb; 132 | } else { 133 | throw new TypeError(`This model only supports MariaDatabase type, was ${newdb.constructor.name}`); 134 | } 135 | } 136 | 137 | /** 138 | * @returns {MariaDatabase} database instance used by this model 139 | */ 140 | static get DB() { 141 | return db; 142 | } 143 | 144 | static get DATASTORE() { 145 | return tableName; 146 | } 147 | 148 | get db() { 149 | return Warehouse.DB; 150 | } 151 | 152 | get datastore() { 153 | return Warehouse.DATASTORE; 154 | } 155 | } 156 | 157 | exports.model = Warehouse; -------------------------------------------------------------------------------- /models/index.js: -------------------------------------------------------------------------------- 1 | const { Utils, Logger } = require('common'); 2 | const Model = require('./Model'); 3 | const models = Utils.requireNamespace('models', 'model'); 4 | 5 | if(global[Model.MODELS_LOADED_FLAG]){ 6 | Logger.error(`Models are being loaded second time. Please check your code. You only need to require('models') once, after which models will be installed to global scope`); 7 | module.exports = models; 8 | } else { 9 | for (let [name, model] of Utils.iterateObject(models)){ 10 | if(!global[name] || !(global[name].prototype instanceof Model)){ 11 | Object.defineProperty(global, name, { 12 | enumerable: true, 13 | writable: false, 14 | value: model 15 | }) 16 | } 17 | } 18 | //mark models as loaded to global scope 19 | global[Model.MODELS_LOADED_FLAG] = true; 20 | module.exports = models; 21 | } -------------------------------------------------------------------------------- /models/models.d.ts: -------------------------------------------------------------------------------- 1 | import * as _User from './User'; 2 | import * as _Comment from './Comment'; 3 | import * as _Product from './Product'; 4 | import * as _Warehouse from './Warehouse'; 5 | 6 | declare global { 7 | const User : typeof _User.model; 8 | const Comment : typeof _Comment.model; 9 | const Product : typeof _Product.model; 10 | const Warehouse : typeof _Warehouse.model; 11 | } -------------------------------------------------------------------------------- /models/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "models", 3 | "version": "0.0.0", 4 | "description": "Models for this application" 5 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "makaroni", 3 | "version": "0.69.0", 4 | "description": "A kickstarter project for Koa2 with React using MVC pattern", 5 | "readme": "README.md", 6 | "license": "MIT", 7 | "dependencies": { 8 | "common": "file:common", 9 | "controllers": "file:controllers", 10 | "database": "file:database", 11 | "ejs": "^2.5.7", 12 | "jsonwebtoken": "^7.4.3", 13 | "kcors": "^2.2.1", 14 | "koa": "^2.3.0", 15 | "koa-body": "^2.3.0", 16 | "koa-convert": "^1.2.0", 17 | "koa-csrf": "^3.0.6", 18 | "koa-jwt": "^3.2.2", 19 | "koa-passport": "^3.0.0", 20 | "koa-router": "^7.2.1", 21 | "koa-static": "^4.0.1", 22 | "middleware": "file:middleware", 23 | "models": "file:models", 24 | "mongodb": "^2.2.31", 25 | "mysql2": "^1.4.2", 26 | "pug": "^2.0.0-rc.3", 27 | "react-redux": "^5.0.6", 28 | "redux": "^3.7.2", 29 | "winston": "^2.3.1" 30 | }, 31 | "peerDependencies": { 32 | "linklocal": "^2.8.1", 33 | "db-migrate": "^0.10.0-beta.24", 34 | "db-migrate-mysql": "^1.1.10" 35 | }, 36 | "devDependencies": { 37 | "babel-core": "^6.26.0", 38 | "babel-loader": "^7.1.2", 39 | "babel-minify": "^0.2.0", 40 | "babel-minify-webpack-plugin": "^0.2.0", 41 | "babel-plugin-transform-async-to-generator": "^6.24.1", 42 | "babel-plugin-transform-regenerator": "^6.26.0", 43 | "babel-preset-env": "^1.6.1", 44 | "babel-preset-react": "^6.24.1", 45 | "browser-sync": "^2.18.13", 46 | "chai": "^4.1.1", 47 | "css-loader": "^0.28.5", 48 | "eslint-plugin-react": "^7.2.1", 49 | "express-to-koa": "^1.0.6", 50 | "graceful-fs": "^4.1.11", 51 | "gulp": "github:gulpjs/gulp#4.0", 52 | "html-webpack-plugin": "^2.30.1", 53 | "koa-webpack": "^1.0.0", 54 | "mocha": "^3.5.0", 55 | "node-sass": "^4.5.3", 56 | "react": "^15.6.1", 57 | "react-dom": "^15.6.1", 58 | "react-hot-loader": "^1.3.1", 59 | "sass-loader": "^6.0.6", 60 | "style-loader": "^0.18.2", 61 | "webpack": "^3.5.5", 62 | "webpack-hot-middleware": "^2.18.2", 63 | "webpack-module-hot-accept": "^1.0.5", 64 | "webpack-stream": "^4.0.0" 65 | }, 66 | "scripts": { 67 | "start-debug": "node --inspect app.js", 68 | "start": "node app.js", 69 | "dev": "gulp dev", 70 | "devserver": "gulp dev && node --inspect app.js", 71 | "test": "mocha ./tests/", 72 | "debug-test": "mocha --inspect --inspect-brk ./tests/", 73 | "sql-migrations-test": "db-migrate up:sql -e test-mariadb", 74 | "nosql-migrations-test": "db-migrate up:nosql -e test-mongodb" 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /tests/dbTest.js: -------------------------------------------------------------------------------- 1 | const { expect } = require('chai'); 2 | const { Utils, Logger } = require('common'); 3 | const { MariaDatabase } = require('database'); 4 | 5 | const db = new MariaDatabase(encodeURI(`mysql://${process.env['MARIA_TEST_USER']}:${process.env['MARIA_TEST_PASS']}@${process.env['MARIA_TEST_HOST']}:3306/test`)); 6 | 7 | const joinTable = 'join_products_warehouses'; 8 | 9 | const products = [ 10 | { 11 | name: 'Thunder Socks', 12 | description: 'The socks of Power™', 13 | price: 5.55 14 | }, 15 | { 16 | name: 'Lightsaber', 17 | description: 'Any Lightsaber > The One True Ring', 18 | price: 3400.00 19 | }, 20 | { 21 | name: 'Koreshluck', 22 | description: 'The power to always fail even with 99% chance of success', 23 | price: 0.00 24 | } 25 | ]; 26 | 27 | const warehouses = [ 28 | { 29 | address: 'Testikylä 6', 30 | info: 'Ihan sopiva osoite, eikö?' 31 | }, 32 | { 33 | address: 'Улица Страстного Десанта 69', 34 | info: 'Все дамы обожают это место' 35 | }, 36 | { 37 | address: 'Exception Square 5', 38 | info: 'Can\'t seem to find a way off it' 39 | } 40 | ]; 41 | 42 | describe('MariaDatabase tests', async function () { 43 | it('Tests that connection works', async function () { 44 | const connection = await db.connect(); 45 | connection.release(); 46 | }); 47 | it('Inserts data', async function () { 48 | let [insertResult, err] = await db.insert('products', products[0]); 49 | //first at least see if the actual operation completes without exceptions lel 50 | expect(insertResult.insertId > 0).to.be.true; 51 | //insert other products 52 | let moreInserts = await db.insert('products', products.slice(1)); 53 | expect(moreInserts.length === 2 && moreInserts.every(result=>result[0].insertId>insertResult.insertId)).to.be.true; 54 | }); 55 | it('Selects data', async function () { 56 | let products = await db.select('products', 'name LIKE ?', '%saber%'); 57 | expect(!products).to.be.false; 58 | let [products1, fields] = await db.select('products', { id: 2 }); 59 | expect(products1.length === 1).to.be.true; 60 | let [products2, fields2] = await db.select('products', { id: 2, name: 'nothing' }); 61 | expect(products2.length).to.equal(0); 62 | }); 63 | it('Updates data', async function () { 64 | let result = await db.update('products', { description: '100% legit updated description' }, 'name LIKE ?', '%saber%'); 65 | expect(result); 66 | }); 67 | it('Selects updated data', async function () { 68 | let [updated, fields] = await db.select('products', 'description LIKE ?', '%legit%'); 69 | expect(updated[0].name).to.equal(products[1].name); 70 | }); 71 | it('Updates multiple rows', async function () { 72 | let result = await db.updateMultiple('products', [ 73 | { 74 | description: 'This really is a fascinating description' 75 | }, 76 | { 77 | description: 'Another fascinating description' 78 | } 79 | ], (query, updateObj, index)=>{ 80 | if(index === 0) return query.where('name = ?').values('Thunder Socks'); 81 | else return query.where('description LIKE ?').values('%fail%'); 82 | }); 83 | expect(result); 84 | let records = await db.select('products', 'description LIKE ?', '%fascinating%'); 85 | expect(records.length).to.equal(2); 86 | }); 87 | it('Deletes data', async function () { 88 | let [result] = await db.delete('products', 'price < ?', 5); //deletes Koreshluck 89 | expect(result.affectedRows).to.equal(1); 90 | let [result1] = await db.delete('products', products.slice(0, products.length-1).map(product=>({name:product.name, price:product.price}))); //deletes the other two by direct property match, excluding description because it was updated above 91 | expect(result1.affectedRows).to.equal(2); 92 | let count = await db.count('products'); 93 | expect(count).to.equal(0); 94 | }); 95 | it('Inserts using multiple statements', async function () { 96 | let productsInserted = await db.insert('products', products); 97 | expect(productsInserted); 98 | let warehousesInserted = await db.insert('warehouses', warehouses); 99 | expect(warehousesInserted); 100 | }); 101 | it('Performs a transaction', async function () { 102 | let [data, fields] = await db.select('warehouses', 'info LIKE ?', '%дамы%'); 103 | let id = data[0].id; 104 | let transaction = db.transaction(async function (db, connection) { 105 | await db.update('warehouses', { address: 'Улица Страстного Десанта 42' }, { id }); 106 | [data, fields] = await db.select('warehouses', 'info LIKE ?', '%дамы%'); 107 | expect(data[0].address).to.equal('Улица Страстного Десанта 42'); 108 | await new Promise((yes, no)=>setTimeout(()=>yes(), 1200)); //delay before committing 109 | return data[0]; 110 | }); 111 | [data, fields] = await db.select('warehouses', id); 112 | expect(data[0].address).to.equal(warehouses[1].address); 113 | let [results, err] = await transaction; 114 | expect(!err); 115 | expect(results !== null); 116 | [data, fields] = await db.select('warehouses', 'id = ?', id); 117 | expect(data[0].address).to.equal('Улица Страстного Десанта 42'); 118 | }); 119 | it('Performs transactions containing mutiple statements for insert, update, select and delete', async function () { 120 | let transactionResult = await db.transaction(async function (db, connection) { 121 | let [products, fields] = await db.select('products'); 122 | expect(products.length).to.equal(3); 123 | let productIds = products.map(p=>p.id); 124 | let [warehouses, fields1] = await db.select('warehouses'); 125 | expect(warehouses.length).to.equal(3); 126 | let warehouseIds = warehouses.map(w=>w.id); 127 | let joins = productIds.map(pid=>({ 128 | product_id: pid, 129 | warehouse_id: Utils.selectRandom(warehouseIds), 130 | quantity: Math.round(Utils.random(1, 1000)) 131 | })); 132 | let [insertResults, err] = await db.insert(joinTable, joins); 133 | expect(insertResults); 134 | let moreRandomEntriesLOLOLOL = Utils.generateArray(Math.round(Utils.random(1,10)), (i)=>[Utils.selectRandom(productIds), Utils.selectRandom(warehouseIds), Math.round(Utils.random(1,1000))]); 135 | await db.insert(joinTable, '(product_id, warehouse_id, quantity) VALUES(?)', ...moreRandomEntriesLOLOLOL); 136 | let [allJoins] = await db.select(joinTable); 137 | expect(allJoins.length).to.equal(joins.length+moreRandomEntriesLOLOLOL.length); 138 | await db.delete(joinTable, 'warehouse_id IN (?)', [Utils.selectRandom(warehouseIds)]); 139 | }); 140 | let cleanupTransaction = await db.transaction(async function(db, connection){ 141 | await connection.query(`ALTER IGNORE TABLE ${joinTable} ADD UNIQUE temp (product_id, warehouse_id)`); 142 | await connection.query(`ALTER TABLE ${joinTable} DROP INDEX temp`); 143 | }); 144 | let count = await db.count(joinTable); 145 | expect(count > 0).to.be.true; 146 | }); 147 | it('Performs two transactions in parallel', async function () { 148 | let connection = await db.connect(); 149 | let cQuery = `SELECT COUNT(*) AS count FROM ${joinTable} INNER JOIN products ON products.id = ${joinTable}.product_id AND products.name LIKE '%Socks%';`; 150 | let intiailCount = (await connection.query(cQuery))[0][0].count; 151 | async function transaction1(db, connection) { 152 | //purchases every item in stock from all warehouses 153 | let [product, fields] = await db.select('products', 'name LIKE ?', '%Socks%'); 154 | let [warehouses] = await db.select('warehouses'); 155 | expect(warehouses.length).to.equal(3); 156 | expect(product.length).to.equal(1); 157 | await db.delete(joinTable, 'product_id = ? AND warehouse_id IN (?)', product[0].id, warehouses.map(w=>w.id)); 158 | return product[0]; 159 | } 160 | async function transaction2(db, connection) { 161 | //adds a stock 162 | let [product, fields] = await db.select('products', 'name LIKE ?', '%Socks%'); //should run before the above is commited 163 | let [warehouses] = await db.select('warehouses'); 164 | expect(warehouses.length).to.equal(3); 165 | expect(product.length).to.equal(1); 166 | await db.insert(joinTable, '(product_id, warehouse_id, quantity) VALUES(?)', [product[0].id, Utils.selectRandom(warehouses).id, Math.round(Utils.random(1,1000))]); 167 | return product[0]; 168 | } 169 | let results = await Promise.all([db.transaction(transaction1), db.transaction(transaction2)]); 170 | //should now have a different count, less than before 171 | let afterCount = (await connection.query(cQuery))[0][0].count; 172 | expect(afterCountdb.collection(User.DATASTORE).count()); 19 | expect(count).to.equal(0); 20 | }); 21 | it('inserts a user into the database', async function(){ 22 | let newUser = new User({username: 'engi', avatar: 'http://i0.kym-cdn.com/photos/images/newsfeed/000/820/444/f64.gif'}); 23 | let inserted = await newUser.save(); 24 | expect(inserted).to.be.an('object'); 25 | expect(newUser.id).to.be.a('string'); 26 | }); 27 | it('inserts multiple users into the database', async function(){ 28 | let newUsers = await User.insert([ 29 | { 30 | username: 'bunny', 31 | avatar: 'http://3.bp.blogspot.com/-fGQcKmfH_hY/UgQb8T-A9WI/AAAAAAAAEwI/cT7SWjTiwg4/s1600/7+normal,+hapless.jpg' 32 | }, 33 | { 34 | username: 'penny', 35 | avatar: 'https://i.ytimg.com/vi/HqkoWv-Jfto/maxresdefault.jpg' 36 | }, 37 | { 38 | username: 'mrnode', 39 | avatar: 'https://www.mrnode.tk/tophatlogo%20(2).png' 40 | } 41 | ]); 42 | expect(newUsers).to.have.length(3); 43 | }) 44 | it('updates users in the database', async function(){ 45 | let updatedCount = await User.update({username: 'penny'}, {favouriteWeapon: 'Jet Hammer'}); 46 | expect(updatedCount).to.equal(1); 47 | let mrNode = await User.find({username: 'mrnode'}); 48 | expect(mrNode.id).to.be.a('string'); 49 | mrNode.favouriteWeapon = 'v8'; 50 | let inserted = await mrNode.save(); 51 | expect(inserted).to.equal(false); 52 | mrNode = await User.find({favouriteWeapon: 'v8'}); 53 | expect(mrNode.favouriteWeapon).to.equal('v8'); 54 | let penny = await User.find({username: 'penny'}); 55 | expect(penny.favouriteWeapon).to.equal('Jet Hammer'); 56 | }) 57 | it('finds many users from the database', async function(){ 58 | let usersWithAvatars = await User.where({avatar: { '$exists': true }}); 59 | expect(usersWithAvatars).to.have.length(4); 60 | }) 61 | it('cleans up all the users in the database', async function(){ 62 | let allUsers = await User.where(); 63 | expect(allUsers).to.have.length(4); 64 | await allUsers[0].delete(); //delete one using instance method 65 | let count = await User.count(); 66 | expect(count).to.equal(3); 67 | let deletedCount = await User.delete({username: allUsers[1].username});//delete a user using static method 68 | expect(deletedCount).to.equal(1); 69 | count = await User.count(); 70 | expect(count).to.equal(2); 71 | await User.delete(); //delete the rest 72 | count = await User.count(); 73 | expect(count).to.equal(0); 74 | }) 75 | }); 76 | //Test associations between user and comment 77 | describe('Comment:', function() { 78 | Logger.info(`Testing comment model. Database ${Comment.DB.url} and collection ${Comment.DATASTORE}`); 79 | it('tests whether connection works', async function() { 80 | const count = await Comment.count(); 81 | expect(count).to.equal(0); 82 | }); 83 | it('posts a comment as a user', async function(){ 84 | //create a user 85 | let user = new User({username: 'XxX_must4p4sk4_XxX'}); 86 | let insered = await user.save(); 87 | expect(insered).to.be.an('object'); 88 | let commentsAdded = await user.addComments('I like trains'); 89 | let foundComment = await Comment.find({userId: user.id}); 90 | expect(foundComment.text).to.equal(commentsAdded[0].text); 91 | }); 92 | it('posts another comment as a user', async function(){ 93 | let user = await User.find({username: 'XxX_must4p4sk4_XxX'}); 94 | let commentsAdded = await user.addComments('N0sc0p3d bi4tch!'); 95 | let count = await Comment.count(); 96 | expect(count).to.equal(2); 97 | }) 98 | it('likes a comment', async function(){ 99 | let user = await User.find({username: 'XxX_must4p4sk4_XxX'}); 100 | let comment = (await user.comments)[0]; 101 | let likes = Math.round(Math.random()*10)+1; 102 | comment.likes += likes; 103 | await comment.save(); 104 | comment = await Comment.find(comment.id); 105 | expect(comment.likes).to.equal(likes); 106 | }) 107 | it('updates an existing comment', async function(){ 108 | let user = await User.find({username: 'XxX_must4p4sk4_XxX'}); 109 | let comment = (await user.comments)[0]; 110 | let text = 'Looks like I was really high that time...'; 111 | comment.text = text; 112 | await comment.save(); 113 | comment = await Comment.find(comment.id); 114 | expect(comment.text).to.equal(text); 115 | }) 116 | it('cleans up all the comments in the database', async function(){ 117 | let users = await User.where(); 118 | const toDelete = []; 119 | for(let i=0; icomment.delete())); 122 | } 123 | await Promise.all(toDelete); 124 | const count = await User.count(); 125 | expect(count).to.equal(0); 126 | const commentCount = await Comment.count(); 127 | expect(commentCount).to.equal(0); 128 | }) 129 | }); 130 | 131 | describe('Tests product and warehouse models:', function(){ 132 | it('adds products and warehouses to the database', async function(){ 133 | let insert1 = await Product.insert({ name: 'Exquisite hat', description: 'It\'s a hat. It\'s exquisite. What else you want?', price: 1955.40 }); 134 | expect(insert1[0].id); 135 | let insert2 = await Product.insert([ 136 | { 137 | name: 'Chessboard', 138 | description: 'A vintage lunchbox', 139 | price: 300.0 140 | }, 141 | { 142 | name: 'A bag of misery', 143 | description: 'Treat yourself(or your (un)loved ones) to some condensed misery in a bag. Comes with a free cyanide pill.', 144 | price: 666.66 145 | } 146 | ]); 147 | expect(insert2.every(r=>r.id)); 148 | let chessboard = await Product.find(`name LIKE ?`, 'Chessboard'); 149 | expect(chessboard.id).to.exist; 150 | }); 151 | it('updates or creates products', async function(){ 152 | await Product.update({ name: 'Box of cox' }, 'price > ? AND price < ?', 200, 400); 153 | let updated = await Product.find('name LIKE ?', '%cox'); 154 | expect(updated.price).to.equal(300); 155 | let allProducts = await Product.where(); 156 | allProducts.forEach(product=>product.price = 350); 157 | await Product.update(allProducts.map(product=>product.serialize(true))); 158 | allProducts = await Product.where(); 159 | expect(allProducts.every(product=>product.price === 350)).to.be.true; 160 | let misery = allProducts.find(product=>product.name.includes('misery')); 161 | misery.price = 420.55; 162 | await misery.save(); 163 | misery.price = -1; 164 | await misery.get(); 165 | expect(misery.price === 420.55); 166 | let theJoj = new Product({ name: 'JOJ', description: 'You want it.', price: 9999.99 }); 167 | await theJoj.save(); 168 | expect(typeof theJoj.id === 'number'); 169 | }); 170 | it('searches products', async function(){ 171 | let searched = await Product.where('name LIKE ? OR description LIKE ?', '%cox%', '%cyanide%'); 172 | expect(searched.length).to.equal(2); 173 | searched = await Product.where('name = \'JOJ\' OR (price > ? AND price < ?)', 3000, 12000); 174 | expect(searched.length === 1); 175 | }); 176 | it('deletes products', async function(){ 177 | let lunchbox = await Product.find('description = ?', 'A vintage lunchbox'); 178 | await lunchbox.delete(); 179 | lunchbox.id = -1; 180 | await lunchbox.get(); 181 | expect(lunchbox.id === -1); 182 | await Product.delete('name = \'JOJ\' OR price < 420'); 183 | let products = await Product.where(); 184 | expect(products.length === 4); 185 | }); 186 | }); 187 | 188 | describe('Tests many to many relation between product and warehouse:', function(){ 189 | it('obtains relation from both sides', async function(){ 190 | let allProducts = await Product.where(); 191 | for(let product of allProducts){ 192 | let warehouses = await product.warehouses; 193 | expect(warehouses.length).to.be.a('number'); 194 | for(let warehouse of warehouses){ 195 | let quantity = await warehouse.quantity(product); 196 | Logger.info(`There are ${(quantity)} of ${product.name} in a warehouse at ${warehouse.address}`); 197 | } 198 | } 199 | let allWarehouses = await Warehouse.where(); 200 | for(let warehouse of allWarehouses){ 201 | let products = await warehouse.products; 202 | expect(products.length).to.be.a('number'); 203 | Logger.info(`There are ${products.length} products at ${warehouse.address}`); 204 | } 205 | }); 206 | it('adds some quantity of random 3 products to a warehouse in 2 transactions', async function(){ 207 | let allProducts = await Product.where(); 208 | let randomProducts = []; 209 | for(let i = 0; i< allProducts.lenth>3 ? 3 : allProducts.length; i++){ 210 | randomProducts.push(Utils.selectRandom(allProducts, true)); 211 | } 212 | let warehouse = (await Warehouse.where())[0]; 213 | let result = await warehouse.add(100, ...randomProducts); 214 | expect(result[0]); 215 | for(let product of randomProducts){ 216 | let warehouses = await product.warehouses; 217 | for(let warehouse of warehouses){ 218 | let quantity = await warehouse.quantity(product); 219 | expect(quantity).to.equal(100); 220 | } 221 | } 222 | randomProducts.splice(0,1); 223 | let result2 = await warehouse.add(50, ...randomProducts); 224 | for(let product of randomProducts){ 225 | let warehouses = await product.warehouses; 226 | for(let warehouse of warehouses){ 227 | let quantity = await warehouse.quantity(product); 228 | expect(quantity).to.equal(150); 229 | } 230 | } 231 | }); 232 | //These kinds of operations should be abstracted behind appropriate model's methods, 233 | //but for sake of testing they are here now, after all - this is all boilerplate code 234 | it('gets the warehouses in which products exist', async function(){ 235 | let connection = await Warehouse.DB.connect(); 236 | const query = new MariaDatabase.SQLQuery('join_products_warehouses'); 237 | query.join('INNER JOIN', 'warehouses', 'warehouse_id', 'id').where('quantity > 0'); 238 | const [rows] = await connection.query({sql: query.toString(), nestTables: true}); 239 | for(let textRow of rows){ 240 | let warehouse = new Warehouse(textRow[Warehouse.DATASTORE]); 241 | expect(warehouse.id).to.exist; 242 | } 243 | connection.release(); 244 | }); 245 | it('searches a product in a warehouse and removes 5 of them when found', async function(){ 246 | const query = new MariaDatabase.SQLQuery(Product.DATASTORE); 247 | query.join('INNER JOIN', 'join_products_warehouses', 'id', 'product_id'); 248 | query.join('INNER JOIN', 'warehouses', 'warehouse_id', 'id', 'join_products_warehouses'); 249 | query.where('price > 5 AND join_products_warehouses.quantity > 5'); 250 | const [joinedData] = await Product.query(query); 251 | expect(joinedData.length).to.be.greaterThan(0); 252 | const records = []; 253 | for(let joinedRecord of joinedData){ 254 | records.push({ 255 | product: new Product(joinedRecord[Product.DATASTORE]), 256 | warehouse: new Warehouse(joinedRecord[Warehouse.DATASTORE]) 257 | }); 258 | } 259 | let theChosenOne = Utils.selectRandom(records); 260 | let quantityBefore = await theChosenOne.warehouse.quantity(theChosenOne.product); 261 | let [purchaseSuccessful, error] = await theChosenOne.warehouse.purchase(theChosenOne.product, 5); 262 | expect(purchaseSuccessful).to.exist; 263 | let quantityNow = await theChosenOne.warehouse.quantity(theChosenOne.product); 264 | expect(quantityBefore-quantityNow).to.equal(5); 265 | }); 266 | }); 267 | 268 | after(async function(){ 269 | Logger.info('Cleanup: Dropping user collection'); 270 | await User.DB.connect().then(db=>db.dropCollection(User.DATASTORE)); 271 | Logger.info('Cleanup: Dropping comment collection'); 272 | await Comment.DB.connect().then(db=>db.dropCollection(Comment.DATASTORE)); 273 | 274 | const connection = await mdb.connect(); 275 | const [tables] = await connection.query('SHOW TABLES'); 276 | const truncates = []; 277 | let query; 278 | for (let tableData of tables) { 279 | for (let tableName of Object.values(tableData)) { 280 | Logger.info('Cleanup: Truncating ' + tableName); 281 | query = `TRUNCATE ${tableName};`; 282 | if(tableName.startsWith('join')) truncates.splice(0,0,query); 283 | else truncates.push(query); 284 | } 285 | } 286 | truncates.splice(0,0,'SET FOREIGN_KEY_CHECKS = 0;'); 287 | truncates.push('SET FOREIGN_KEY_CHECKS = 1;'); 288 | await connection.query(truncates.join('\n')); 289 | connection.release(); 290 | }) 291 | 292 | //*/ -------------------------------------------------------------------------------- /views/error.pug: -------------------------------------------------------------------------------- 1 | extends layout.pug 2 | 3 | block body 4 | h1= error.message || 'An error occurred while processing your request' 5 | p Pretend like you know what this means: 6 | pre= error.stack -------------------------------------------------------------------------------- /views/index.pug: -------------------------------------------------------------------------------- 1 | extends layout.pug 2 | 3 | block body 4 | h1 Setting up 'le wonderful application stack: 5 | ul 6 | each item in list 7 | li= `${item.name}: ${item.completed ? '✓' : '❌'}` -------------------------------------------------------------------------------- /views/layout.pug: -------------------------------------------------------------------------------- 1 | 2 | html(lang="en") 3 | head 4 | meta(charset="UTF-8") 5 | meta(name="viewport", content="width=device-width, initial-scale=1.0") 6 | meta(http-equiv="X-UA-Compatible", content="ie=edge") 7 | link(href="https://fonts.googleapis.com/css?family=Open+Sans" rel="stylesheet") 8 | link(rel="stylesheet", href="app.css" type="text/css") 9 | title Time to Koad 10 | body 11 | block navigation 12 | menu(type="toolbar") 13 | block body 14 | block footer 15 | aside: p Copyright(c) 2017 Koarse Koalition of Koalas (KKK) 16 | block scripts 17 | script(src="app.js") -------------------------------------------------------------------------------- /views/notfound.pug: -------------------------------------------------------------------------------- 1 | extends layout.pug 2 | 3 | block body 4 | h1 Damn, son, where'd you (not) find this? --------------------------------------------------------------------------------