"use strict";
/* jshint node: true */
var i = require('i')();
var fs = require('fs');
var path = require('path');
var util = require('util');
var P = require('bluebird');
var V = require('validator');
var EventEmitter = require('events');
var debug = require('debug')('emmo-model:index');
const {each} = require('./lib/functions.js');
var Session = require('./lib/session.js');
var Expression = require('./lib/expression.js');
var Migrator = require('./lib/migrator.js');
var Store = require('./lib/store.js');
var buildModel = require('./lib/model.js');
var allValidators = Object.keys(V).filter(fn => fn.startsWith('is') || [ 'contains', 'matches', 'equals' ].indexOf(fn) >= 0);
P.longStackTraces();
/**
* A object to contain Entity definition
*
* @typedef {object} Entity
* @property {string} tableName
* @property {object<string, Property>} properties
* @property {string[]} propertyNames
* @property {string[]} updatableNames
* @property {string[]} inputableNames
* @property {string[]} primaryKeyNames
* @property {string} autoIncrementName
*/
/**
* A object to contain Property definition
* Note allowNull property will cause a empty string validation for string property.
*
* @example
*
* {
* id: { type: 'bigint', autoIncrement: true, primaryKey: true },
* account: { type: 'string', length: 50, allowNull: false }, // allowNull will reject '' for string type as well
* password: { type: 'string', virtual: true, isLength: { min: 5, 20 } }, // virtual property will not be mapped to db
* repassword: { type: 'string', virtual: true, validators: [ // this is how you define customize validators
* function(value) {
* return this.password === value;
* }
* ]},
* passwordHash: { type: 'string', length: 50, input: false }, // will be ignored in User.input convertion
* age: { type: 'int', isInt: { min: 18, max: 120 } },
* email: { type: 'string', isEmail: true },
* }
*
*
* @typedef Property
* @type {Column}
* @property {string} columnName
* @property {boolean} [virtual=false] only in memory, do not map to database
* @property {boolean} [input=true] accept input from user
* @property {string} [dateFormat=ISO8601] how to parse Date @see {@link http://momentjs.com/docs/#/parsing/}
* @property {boolean|boolean} [autoTrim=true] trim space characters for string type, pass 'length' to trancate by length property
* @property {string|boolean} [index] assign same index name to multiple columns will create a composite index
* @property {boolean} [unique] create a unique index or set existing index to unique
* @property {boolean} [desc=true] create a descending index
* @property {string} [refer] build up foreign key reference
* @property {string} [referName] specify different referNames to refer same table multiple times
* @property {string} [onDelete] specify onDelete action: 'CASCADE', 'SET NULL' ...
* @property {string} [onUpdate] specify onUpdate action
* @property {string} [message] error message when validation failure
* @property {array|boolean} [VALIDATION] isEmail: true, isInt: { min: 1 }
* {@link https://www.npmjs.com/package/validator|validator}
* @property {Validator[]} [validators]
*/
/**
* EmmoModel holds all entities definitions, and a Database Server for you.
* <p>EmmoModel is a EventEmitter, you can subscribe events by once/on</p>
* @constructor
* @param {EmmoModel} [parent]
*/
function EmmoModel(parent) {
// initialize definition, add _Migration model to store model definition.
EventEmitter.call(this);
this.parent = parent;
/**
* store all extra information than Model for runtime
*
* @type {object.<string, Entity>}
*/
this.entities = parent ? parent.entities : {};
/**
* store all Models you ever defined.
*
* @type {object.<string, Model>}
*/
this.models = {};
// clone all models to bind this instance.
if (parent) {
for (const name in parent.entities) {
this.models = buildModel(this, name, parent.entities[name]);
}
}
this.define('_Migration', {
uid: { type: 'bigint', primaryKey: true, allowNull: false },
name: { type: "string", length: 50, unique: true },
models: { type: "string" }
});
}
util.inherits(EmmoModel, EventEmitter);
/**
* define a new Model
*
* @param {string} name singular
* @param {object.<string, Property>} properties KEY as property name
* @param {string|object} [tableOptions|tableName=names] plural
* @returns {Model}
*/
EmmoModel.prototype.define = function(name, properties, tableOptions) {
// build up entity
if (typeof(tableOptions) === 'string') {
tableOptions = {
tableName: tableOptions
};
} else if (!tableOptions) {
tableOptions = {};
}
var tableName = tableOptions.tableName || i.pluralize(name);
delete tableOptions.tableName;
var entity = {
tableName: tableName,
properties: properties,
propertyNames: [],
updatableNames: [],
inputableNames: [],
primaryKeyNames: [],
requiredNames: [],
autoIncrementName: '',
tableOptions: tableOptions
};
each(properties, (property, name) => {
property.$name = name;
// create validator for property
if (!Array.isArray(property.validators))
property.validators = [];
if (property.autoIncrement) {
property.validators.push(function autoIncrement(value) {
return Number.isInteger(value * 1) && value > 0;
});
} else {
if (property.length > 0) {
property.validators.push(function length(value) {
return (value.toString()).length <= property.length;
});
}
for (const validatorName of allValidators.filter(v => v in property)) {
var parameter = property[validatorName];
var validator;
if (parameter === true) {
validator = function(value) {
return V[validatorName](value);
};
} else {
validator = function(value) {
return V[validatorName](value, parameter);
};
}
validator.reason = validatorName;
property.validators.push(validator);
};
}
// columnName equals to property name defaulty
if (property.virtual !== true) {
property.columnName = property.columnName || name;
entity.propertyNames.push(name);
}
// find out autoIncrement and updatable properties
if (property.autoIncrement)
entity.autoIncrementName = name;
else if (property.virtual !== true)
entity.updatableNames.push(name);
// collect inputable properties
if (property.input !== false) {
entity.inputableNames.push(name);
if (!property.autoIncrement && property.allowNull === false && property.defaultValue !== null || property.defaultValue !== undefined) {
entity.requiredNames.push(name);
}
}
// collect primary key properties
if (property.primaryKey === true)
entity.primaryKeyNames.push(name);
});
if (entity.primaryKeyNames.length === 0)
throw new Error(name + ' has not primary key');
// save up
this.entities[name] = entity;
// build up model
var Model = buildModel(this, name, entity);
this.models[name] = Model;
return Model;
};
/**
* @typedef InitOptions
* @type {object}
* @property {string} [modelsPath='./models'] path to model files folder
* @property {string} [migrationsPath='./migrations'] path to migration files folder
* @property {string} [dialect=pg] 'pg' is only option for now.
* @property {string} database ORIGIN database name, migration will be created base on it
* @property {string} connectionString need to replace database name with %s
*/
/**
* init
* 1. should be fired up during app startup process.
* 2. model need to require this file, so this prcoess can't be in constructor
* 3. share definition among multiple EmmoModel
*
* @param {InitOptions|string} [optionsOrConfigPath]
* @param {object} store should provide add/remove/exists/getAll as ./lib/store.js did
*/
EmmoModel.prototype.init = function(options, store) {
if (this.inited)
return this;
this.inited = true;
if (!options) {
this.configPath = path.resolve('./em.json');
this.config = require(this.configPath);
} else if (typeof(options) === 'string') {
this.configPath = options;
this.config = require(path.resolve(this.configPath));
} else {
this.config = options;
var absPath = path.resolve('./em.json');
if (fs.existsSync(absPath)) {
this.config = Object.assign({}, require(absPath), options);
}
}
this.config = Object.assign({
modelsPath: './models',
migrationsPath:'./migrations',
dialect: 'pg',
connectionString: ''
}, this.config);
if (!this.config.connectionString)
throw new Error('init failure: connectionString can not be empty');
if (!this.config.database)
throw new Error('init failure: database can not be empty');
if (!this.config.modelsPath)
throw new Error('init failure: modelsPath can not be empty');
if (!this.config.migrationsPath)
throw new Error('init failure: migrationsPath can not be empty');
this.store = store || new Store(this);
this.modelsPath = path.resolve(this.config.modelsPath);
this.migrationsPath = path.resolve(this.config.migrationsPath);
// make sure we are not sharing the models among multiple em inst.
if (!this.parent && fs.existsSync(this.config.modelsPath)) {
// load models
var config = this.config;
for (const fileName of fs.readdirSync(config.modelsPath)) {
if (/\.js$/.test(fileName)) {
require(path.resolve(config.modelsPath, fileName));
}
};
}
// load dialect
this.agent = require('./dialect/' + this.config.dialect + '.js');
debugger
// copy database functions from dialect
for (const n in this.agent.functions) {
const f = this.agent.functions[n];
this[n] = function() {
return new Expression(f.apply(this.agent.functions, arguments), this, 'function');
};
}
// copy database comparators from dialect
for (const n in this.agent.comparators) {
const f = this.agent.comparators[n];
this[n] = function() {
return new Expression(f.apply(this.agent.comparators, arguments), this, 'comparator');
};
}
return this;
};
EmmoModel.prototype.getAllDatabases = function() {
var self = this;
self.init();
return self.store.getChildren().then(function(children) {
return children.concat(self.config.database);
});
}
/**
* create a new EmmoModel instance to a new server with same definition, like for backup/duplicate
*
* @param {InitOptions} options
* @returns {EmmoModel}
*/
EmmoModel.prototype.spawn = function(options) {
return new EmmoModel(this).init(options);
};
/**
* @callback EmmoModel~job
* @param {Session} db
* @retuns {Promise}
*/
/**
* this is where you perform database operations
* 1. run operation over specific database em.scope(databasename, job);
* 2. run operation over ORIGIN database em.scope(job);
* 3. job is a function take a session instance to perform operation
* 4. you must return a promise in job function so scope can release connection when finish
* 5. ORIGIN normally refer to `database` in your_project/em.json file.
* 6. you can perform TRANSCACTION in a scope.
*
* @example
* var em = require('emmo-model');
* em.scope('db1', funciton(db) {
* return db.all('User');
* }).then(function(users) {
* console.log(users);
* });
*
* @param {string} [database=ORIGIN] which database you want to operate
* @param {EmmoModel~job} job perform operation with session instance, need to return promise
* @returns {promise}
*/
EmmoModel.prototype.scope = function(arg1, arg2) {
if (!this.inited)
throw new Error('you need to call init() before running any operation');
if (arg1 instanceof Session)
return arg2(arg1);
var database, job, self = this;
if (typeof(arg2) === 'function') {
job = arg2;
database = arg1;
} else {
job = arg1;
}
database = database || this.config.database;
var session = new Session(this, database);
var promise = job(session);
if (!promise || typeof(promise.then) !== 'function')
throw new Error("Must return a promise");
return promise.then(function(data) {
session.close();
return data;
}, function(err) {
session.query('ROLLBACK');
session.close();
return P.reject(err);
});
};
/**
* Transaction support
*/
EmmoModel.prototype.transact = function(arg1, arg2) {
var database = typeof(arg1) === 'string' ? arg1 : null;
var job = typeof(arg2) === 'function' ? arg2 : arg1;
return this.scope(database, function(db) {
return db.begin().then(function() {
return job(db);
}).tap(function() {
return db.commit();
}).catch(function(err) {
debug('TRANSACTION FAIL: ', err);
return db.rollback().then(() => P.reject(err));
});
});
};
/**
* Perform operation for all databases
*/
EmmoModel.prototype.all = function(job) {
var self = this;
return this.getAllDatabases().each(function(database) {
return self.scope(database, job);
});
};
/**
* lazy load migrator
*
* @returns {Migrator}
*/
EmmoModel.prototype.getMigrator = function() {
if (!this.migrator) {
this.migrator = new Migrator(this);
}
return this.migrator;
};
/**
* create a database base on definition
*
* @fires EmmoModel#created
* @param {string} [database=ORIGIN]
* @returns {Promise}
*/
EmmoModel.prototype.create = function(database) {
this.init();
var self = this, agent = this.agent;
const debug = './initial-debug.sql';
if (fs.existsSync(debug))
fs.unlinkSync(debug);
return self.scope(agent.defaultDatabase, function(db) {
// step 1: connect to server default database, run CREATE DATABASE statement
return db.query(self.agent.createDatabase(database)).error(function(err) {
//console.log('ERROR', err);
//console.log(err.stack);
err.code = err.code || 'E_CREATE_DB_FAIL'; // either connection failure or creation failure.
return P.reject(err);
});
}).then(function() {
// step 2: fetch DATABASE STRUCTURE CREATION SCRIPT, connect to new database and apply it!
var migrator = self.getMigrator();
return self.scope(database, function(db) {
return db.query(migrator.getInitialSQL()).then(function() {
// insert new migration history record
return db.insert('_Migration', migrator.lastMigrationData());
});
}).error(function(err) {
// seems thing went south, create a debug file, as generated SQL Script along with ERROR information.
fs.writeFileSync(debug, migrator.getInitialSQL() + '\n\n\n\n\n\n' + util.inspect(err));
// then remove useless database so that we can re-created it next time.
return self.remove(database).finally(function() {
return P.reject(new Error('An error ocurred during initialation, may causued by wrong model definition, check initial-debug.sql in your project folder'));
});
});
}).tap(function() {
// step 3: fire out event, and save information to em.json, so we know how many databases we have currently
/**
* when the database is created first time, you can plant seed data at this point, like insert admin user.
* @event EmmoModel#created
* @param {string} database
*/
self.emit('created', database);
if (database !== self.config.database)
return self.store.add(database);
});
};
/**
* remove a database from server
*
* @fires EmmoModel#removed
* @param {string} [database=ORIGIN]
* @returns {Promise}
*/
EmmoModel.prototype.remove = function(database) {
this.init();
var self = this, agent = this.agent;
// abadon spare connections in pool so we can remove target database
return self.agent.dispose().then(function() {
return self.scope(agent.defaultDatabase, function(db) {
return db.query(self.agent.dropDatabase(database));
}).tap(function() {
/**
* when database is removed
* @event EmmoModel#removed
* @param {string} database
*/
self.emit('removed', database);
return self.store.remove(database);
});
});
};
/**
* perform database structure synchoronization.
* 1. missed databases will be created automatically.
* 2. existing databases will be migrated smartly.
* 3. whenever shit happens during creating process, it will be deleted.
* 4. migration failure should not affect existing databases.
*
* @fires EmmoModel#created single database is created
* @fires EmmoModel#migrated single database is migrated
* @fires EmmoModel#synced single database is synced
* @fires EmmoModel#ready all databases are ready
* @param {string|array} [databases=ALL]
*/
EmmoModel.prototype.sync = function(databases) {
this.init();
var self = this, p;
if (databases) {
if (typeof(databases) === 'string')
databases = [ databases ];
if (databases.length)
p = P.resolve(databases);
}
p = p || this.getAllDatabases();
return p.each(function(database) {
return self.create(database).error(function(err) {
// if creating process has failed, it means we should do migration.
if (err.code !== 'E_CREATE_DB_FAIL')
return P.reject(err);
var migrator = self.getMigrator();
return self.transact(database, function(db) {
return migrator.run(db);
}).then(function(){
/**
* when the database is migrated, you can perform initialize against database here.
* @event EmmoModel#migrated
* @param {string} databaseName
*/
self.emit('migrated', database);
});
}).then(function() {
self.emit('synced', database);
});
}).then(function() {
/**
* when all databases are created or migrated sucessfully, you can do some system bootstrap here.
* @event EmmoModel#ready
*/
self.emit('ready');
});
};
/**
* recreate ORIGIN database, useful for unit test scenario
*
* @returns {Promise}
*/
EmmoModel.prototype.dropCreate = function() {
this.init();
var self = this;
return self.remove(self.config.database).finally(function() {
return self.create(self.config.database);
});
};
/**
* export a ready to use instance, you can spawn a new Instance
*/
var em = module.exports = new EmmoModel();
em.pair = function() {
return '_' + Math.random();
}
em.or = function() {
return '$' + Math.random();
}
/**
* create new EmmoModel instance
*
* @example
*
* var server2 = require('emmo-model').new();
* server2.init(...);
*/
em.new = function() {
return new EmmoModel();
};
var DEV = process.env.NODE_ENV !== 'production';
/**
* easy way to create RESTful api.
*
* @example
* var em = require('emmo-model');
* var User = require('../models/user.js');
*
* app.get('/api/users/:id', em.mount(req => User.find(req.params.id)));
*/
em.mount = function(handler) {
return function(req, res, next) {
var promise = handler(req, res, next);
if (typeof(promise.then) !== 'function')
next(new Error('Expect returning a promise instance'));
promise.then(function(result) {
res.json({ code: 'SUCCESS', result: result });
}).catch(next);
};
};
/**
* @typedef ModelErrorInfo
* @type {object}
* @property {string} entityName
* @property {Entity} entity
* @property {string} propertyName
* @property {Property} property
* @property {string} reason
*/
/**
* Create a error for Model convertion/validation, replace this to customize your error instance
*
* @callback Model~newErr
* @param {string} code error code
* @param {ModelErrorInfo} [info] validator name/relevant definition/customize validator function name
*/
em.newModelErr = function(code, info) {
var message;
switch (code) {
case 'E_DATA_EMPTY':
message = 'Input data can not be a empty object';
break;
case 'E_TYPE_ERROR':
message = 'Illegal input value for ' + info.entityName + '.' + info.propertyName;
break;
case 'E_VALIDATION_FAIL':
message = info.property.message || 'Validation fail for ' + info.entityName + '.' + info.propertyName + ' ' + info.reason;
break;
}
var error = new Error(message);
error.code = code;
error.description = message;
return error;
};