diff --git a/index.js b/index.js index 8cacef7..44eb915 100644 --- a/index.js +++ b/index.js @@ -4,3 +4,17 @@ exports.SQLConnector = exports.SqlConnector = require('./lib/sql'); exports.ParameterizedSQL = exports.SQLConnector.ParameterizedSQL; exports.Transaction = require('./lib/transaction'); +exports.ModelBuilder = exports.LDL = require('loopback-model/lib/model-builder'); +exports.DataSource = exports.Schema = require('./lib/datasource.js').DataSource; +exports.ModelBaseClass = require('loopback-model/lib/model'); +exports.GeoPoint = require('loopback-model').Geo.GeoPoint; +exports.ValidationError = require('loopback-model/lib/validations').ValidationError; + +Object.defineProperty(exports, 'version', { + get: function() {return require('./package.json').version;} +}); + +var commonTest = './test/common_test'; +Object.defineProperty(exports, 'test', { + get: function() {return require(commonTest);} +}); diff --git a/lib/connectors/memory.js b/lib/connectors/memory.js new file mode 100644 index 0000000..d397644 --- /dev/null +++ b/lib/connectors/memory.js @@ -0,0 +1,787 @@ +var util = require('util'); +var Connector = require('../..').Connector; +var geo = require('loopback-model').Geo; +var utils = require('loopback-model/lib/utils'); +var fs = require('fs'); +var async = require('async'); + +/** + * Initialize the Memory connector against the given data source + * + * @param {DataSource} dataSource The loopback-datasource-juggler dataSource + * @param {Function} [callback] The callback function + */ +exports.initialize = function initializeDataSource(dataSource, callback) { + dataSource.connector = new Memory(null, dataSource.settings); + dataSource.connector.connect(callback); +}; + +exports.Memory = Memory; +exports.applyFilter = applyFilter; + +function Memory(m, settings) { + if (m instanceof Memory) { + this.isTransaction = true; + this.cache = m.cache; + this.ids = m.ids; + this.constructor.super_.call(this, 'memory', settings); + this._models = m._models; + } else { + this.isTransaction = false; + this.cache = {}; + this.ids = {}; + this.constructor.super_.call(this, 'memory', settings); + } +} + +util.inherits(Memory, Connector); + +Memory.prototype.getDefaultIdType = function() { + return Number; +}; + +Memory.prototype.getTypes = function() { + return ['db', 'nosql', 'memory']; +}; + +Memory.prototype.connect = function (callback) { + if (this.isTransaction) { + this.onTransactionExec = callback; + } else { + this.loadFromFile(callback); + } +}; + +function serialize(obj) { + if(obj === null || obj === undefined) { + return obj; + } + return JSON.stringify(obj); +} + +function deserialize(dbObj) { + if(dbObj === null || dbObj === undefined) { + return dbObj; + } + if(typeof dbObj === 'string') { + return JSON.parse(dbObj); + } else { + return dbObj; + } +} + +Memory.prototype.getCollection = function(model) { + var modelClass = this._models[model]; + if (modelClass && modelClass.settings.memory) { + model = modelClass.settings.memory.collection || model; + } + return model; +} + +Memory.prototype.initCollection = function(model) { + this.collection(model, {}); + this.collectionSeq(model, 1); +} + +Memory.prototype.collection = function(model, val) { + model = this.getCollection(model); + if (arguments.length > 1) this.cache[model] = val; + return this.cache[model]; +}; + +Memory.prototype.collectionSeq = function(model, val) { + model = this.getCollection(model); + if (arguments.length > 1) this.ids[model] = val; + return this.ids[model]; +}; + +Memory.prototype.loadFromFile = function(callback) { + var self = this; + var hasLocalStorage = typeof window !== 'undefined' && window.localStorage; + var localStorage = hasLocalStorage && this.settings.localStorage; + + if (self.settings.file) { + fs.readFile(self.settings.file, {encoding: 'utf8', flag: 'r'}, function (err, data) { + if (err && err.code !== 'ENOENT') { + callback && callback(err); + } else { + parseAndLoad(data); + } + }); + } else if(localStorage) { + var data = window.localStorage.getItem(localStorage); + data = data || '{}'; + parseAndLoad(data); + } else { + process.nextTick(callback); + } + + function parseAndLoad(data) { + if (data) { + try { + data = JSON.parse(data.toString()); + } catch(e) { + return callback(e); + } + + self.ids = data.ids || {}; + self.cache = data.models || {}; + } else { + if(!self.cache) { + self.ids = {}; + self.cache = {}; + } + } + callback && callback(); + } +}; + +/*! + * Flush the cache into the json file if necessary + * @param {Function} callback + */ +Memory.prototype.saveToFile = function (result, callback) { + var self = this; + var file = this.settings.file; + var hasLocalStorage = typeof window !== 'undefined' && window.localStorage; + var localStorage = hasLocalStorage && this.settings.localStorage; + if (file) { + if(!self.writeQueue) { + // Create a queue for writes + self.writeQueue = async.queue(function (task, cb) { + // Flush out the models/ids + var data = JSON.stringify({ + ids: self.ids, + models: self.cache + }, null, ' '); + + fs.writeFile(self.settings.file, data, function (err) { + cb(err); + task.callback && task.callback(err, task.data); + }); + }, 1); + } + // Enqueue the write + self.writeQueue.push({ + data: result, + callback: callback + }); + } else if (localStorage) { + // Flush out the models/ids + var data = JSON.stringify({ + ids: self.ids, + models: self.cache + }, null, ' '); + window.localStorage.setItem(localStorage, data); + process.nextTick(function () { + callback && callback(null, result); + }); + } else { + process.nextTick(function () { + callback && callback(null, result); + }); + } +}; + +Memory.prototype.define = function defineModel(definition) { + this.constructor.super_.prototype.define.apply(this, [].slice.call(arguments)); + var m = definition.model.modelName; + if(!this.collection(m)) this.initCollection(m); +}; + +Memory.prototype._createSync = function(model, data, fn) { + // FIXME: [rfeng] We need to generate unique ids based on the id type + // FIXME: [rfeng] We don't support composite ids yet + var currentId = this.collectionSeq(model); + if (currentId === undefined) { // First time + currentId = this.collectionSeq(model, 1); + } + var id = this.getIdValue(model, data) || currentId; + if (id > currentId) { + // If the id is passed in and the value is greater than the current id + currentId = id; + } + this.collectionSeq(model, Number(currentId) + 1); + + var props = this._models[model].properties; + var idName = this.idName(model); + id = (props[idName] && props[idName].type && props[idName].type(id)) || id; + this.setIdValue(model, data, id); + if (!this.collection(model)) { + this.collection(model, {}); + } + + if (this.collection(model)[id]) + return fn(new Error('Duplicate entry for ' + model + '.' + idName)); + + this.collection(model)[id] = serialize(data); + fn(null, id); +}; + +Memory.prototype.create = function create(model, data, options, callback) { + var self = this; + this._createSync(model, data, function(err, id) { + if (err) { + return process.nextTick(function() { + callback(err); + }); + }; + self.saveToFile(id, callback); + }); +}; + +Memory.prototype.updateOrCreate = function (model, data, options, callback) { + var self = this; + this.exists(model, self.getIdValue(model, data), options, function (err, exists) { + if (exists) { + self.save(model, data, options, function(err, data) { + callback(err, data, { isNewInstance: false }); + }); + } else { + self.create(model, data, options, function (err, id) { + self.setIdValue(model, data, id); + callback(err, data, { isNewInstance: true }); + }); + } + }); +}; + +Memory.prototype.findOrCreate = function(model, filter, data, callback) { + var self = this; + var nodes = self._findAllSkippingIncludes(model, filter); + var found = nodes[0]; + + if(!found) { + // Calling _createSync to update the collection in a sync way and to guarantee to create it in the same turn of even loop + return self._createSync(model, data, function(err, id) { + if (err) return callback(err); + self.saveToFile(id, function(err, id) { + self.setIdValue(model, data, id); + callback(err, data, true); + }); + }); + } + + if (!filter || !filter.include) { + return process.nextTick(function() { + callback(null, found, false); + }); + } + + self._models[model].model.include(nodes[0], filter.include, {}, function(err, nodes) { + process.nextTick(function() { + if (err) return callback(err); + callback(null, nodes[0], false); + }); + }); +}; + +Memory.prototype.save = function save(model, data, options, callback) { + var self = this; + var id = this.getIdValue(model, data); + var cachedModels = this.collection(model); + var modelData = cachedModels && this.collection(model)[id]; + modelData = modelData && deserialize(modelData); + if (modelData) { + data = merge(modelData, data); + } + this.collection(model)[id] = serialize(data); + this.saveToFile(data, function(err) { + callback(err, self.fromDb(model, data), { isNewInstance: !modelData }); + }); +}; + +Memory.prototype.exists = function exists(model, id, options, callback) { + process.nextTick(function () { + callback(null, this.collection(model) && this.collection(model).hasOwnProperty(id)); + }.bind(this)); +}; + +Memory.prototype.find = function find(model, id, options, callback) { + process.nextTick(function () { + callback(null, id in this.collection(model) && this.fromDb(model, this.collection(model)[id])); + }.bind(this)); +}; + +Memory.prototype.destroy = function destroy(model, id, options, callback) { + var exists = this.collection(model)[id]; + delete this.collection(model)[id]; + this.saveToFile({ count: exists ? 1 : 0 }, callback); +}; + +Memory.prototype.fromDb = function (model, data) { + if (!data) return null; + data = deserialize(data); + var props = this._models[model].properties; + for (var key in data) { + var val = data[key]; + if (val === undefined || val === null) { + continue; + } + if (props[key]) { + switch (props[key].type.name) { + case 'Date': + val = new Date(val.toString().replace(/GMT.*$/, 'GMT')); + break; + case 'Boolean': + val = Boolean(val); + break; + case 'Number': + val = Number(val); + break; + } + } + data[key] = val; + } + return data; +}; + +function getValue(obj, path) { + if (obj == null) { + return undefined; + } + var keys = path.split('.'); + var val = obj; + for (var i = 0, n = keys.length; i < n; i++) { + val = val[keys[i]]; + if (val == null) { + return val; + } + } + return val; +} + +Memory.prototype._findAllSkippingIncludes = function(model, filter) { + var nodes = Object.keys(this.collection(model)).map(function (key) { + return this.fromDb(model, this.collection(model)[key]); + }.bind(this)); + + if (filter) { + if (!filter.order) { + var idNames = this.idNames(model); + if (idNames && idNames.length) { + filter.order = idNames; + } + } + // do we need some sorting? + if (filter.order) { + var orders = filter.order; + if (typeof filter.order === "string") { + orders = [filter.order]; + } + orders.forEach(function (key, i) { + var reverse = 1; + var m = key.match(/\s+(A|DE)SC$/i); + if (m) { + key = key.replace(/\s+(A|DE)SC/i, ''); + if (m[1].toLowerCase() === 'de') reverse = -1; + } + orders[i] = {"key": key, "reverse": reverse}; + }); + nodes = nodes.sort(sorting.bind(orders)); + } + + var nearFilter = geo.nearFilter(filter.where); + + // geo sorting + if (nearFilter) { + nodes = geo.filter(nodes, nearFilter); + } + + // do we need some filtration? + if (filter.where && nodes) + nodes = nodes.filter(applyFilter(filter)); + + // field selection + if (filter.fields) { + nodes = nodes.map(utils.selectFields(filter.fields)); + } + + // limit/skip + var skip = filter.skip || filter.offset || 0; + var limit = filter.limit || nodes.length; + nodes = nodes.slice(skip, skip + limit); + } + return nodes; + + function sorting(a, b) { + var undefinedA, undefinedB; + + for (var i = 0, l = this.length; i < l; i++) { + var aVal = getValue(a, this[i].key); + var bVal = getValue(b, this[i].key); + undefinedB = bVal === undefined && aVal !== undefined; + undefinedA = aVal === undefined && bVal !== undefined; + + if (undefinedB || aVal > bVal) { + return 1 * this[i].reverse; + } else if (undefinedA || aVal < bVal) { + return -1 * this[i].reverse; + } + } + + return 0; + } +}; + +Memory.prototype.all = function all(model, filter, options, callback) { + var self = this; + var nodes = self._findAllSkippingIncludes(model, filter); + + process.nextTick(function() { + if (filter && filter.include) { + self._models[model].model.include(nodes, filter.include, options, callback); + } else { + callback(null, nodes); + } + }); +}; + +function applyFilter(filter) { + var where = filter.where; + if (typeof where === 'function') { + return where; + } + var keys = Object.keys(where); + return function (obj) { + return keys.every(function(key) { + if(key === 'and' || key === 'or') { + if(Array.isArray(where[key])) { + if(key === 'and') { + return where[key].every(function(cond) { + return applyFilter({where: cond})(obj); + }); + } + if(key === 'or') { + return where[key].some(function(cond) { + return applyFilter({where: cond})(obj); + }); + } + } + } + + var value = getValue(obj, key); + // Support referencesMany and other embedded relations + // Also support array types. Mongo, possibly PostgreSQL + if (Array.isArray(value)) { + var matcher = where[key]; + // The following condition is for the case where we are querying with + // a neq filter, and when the value is an empty array ([]). + if (matcher.neq !== undefined && value.length <= 0) { + return true; + } + return value.some(function (v, i) { + var filter = {where: {}}; + filter.where[i] = matcher; + return applyFilter(filter)(value); + }); + } + + + if (test(where[key], value)) { + return true; + } + + // If we have a composed key a.b and b would resolve to a property of an object inside an array + // then, we attempt to emulate mongo db matching. Helps for embedded relations + var dotIndex = key.indexOf('.'); + var subValue = obj[key.substring(0, dotIndex)]; + if (dotIndex !== -1 && Array.isArray(subValue)) { + var subFilter = {where: {}}; + var subKey = key.substring(dotIndex+1); + subFilter.where[subKey] = where[key]; + return subValue.some(applyFilter(subFilter)); + } + + return false; + }); + } + + function toRegExp(pattern) { + if (pattern instanceof RegExp) { + return pattern; + } + var regex = ''; + // Escaping user input to be treated as a literal string within a regular expression + // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions#Writing_a_Regular_Expression_Pattern + pattern = pattern.replace(/([.*+?^=!:${}()|\[\]\/\\])/g, "\\$1"); + for (var i = 0, n = pattern.length; i < n; i++) { + var char = pattern.charAt(i); + if (char === '\\') { + i++; // Skip to next char + if (i < n) { + regex += pattern.charAt(i); + } + continue; + } else if (char === '%') { + regex += '.*'; + } else if (char === '_') { + regex += '.'; + } else if (char === '.') { + regex += '\\.'; + } else if (char === '*') { + regex += '\\*'; + } + else { + regex += char; + } + } + return regex; + } + + function test(example, value) { + if (typeof value === 'string' && (example instanceof RegExp)) { + return value.match(example); + } + + if (example === undefined) { + return undefined; + } + + if (typeof example === 'object' && example !== null) { + if (example.regexp) { + return value ? value.match(example.regexp) : false; + } + + // ignore geo near filter + if (example.near) { + return true; + } + + if (example.inq) { + // if (!value) return false; + for (var i = 0; i < example.inq.length; i++) { + if (example.inq[i] == value) { + return true; + } + } + return false; + } + + if ('neq' in example) { + return compare(example.neq, value) !== 0; + } + + if ('between' in example ) { + return ( testInEquality({gte:example.between[0]}, value) && + testInEquality({lte:example.between[1]}, value) ); + } + + if (example.like || example.nlike) { + + var like = example.like || example.nlike; + if (typeof like === 'string') { + like = toRegExp(like); + } + if (example.like) { + return !!new RegExp(like).test(value); + } + + if (example.nlike) { + return !new RegExp(like).test(value); + } + } + + if (testInEquality(example, value)) { + return true; + } + } + // not strict equality + return (example !== null ? example.toString() : example) + == (value != null ? value.toString() : value); + } + + /** + * Compare two values + * @param {*} val1 The 1st value + * @param {*} val2 The 2nd value + * @returns {number} 0: =, positive: >, negative < + * @private + */ + function compare(val1, val2) { + if(val1 == null || val2 == null) { + // Either val1 or val2 is null or undefined + return val1 == val2 ? 0 : NaN; + } + if (typeof val1 === 'number') { + return val1 - val2; + } + if (typeof val1 === 'string') { + return (val1 > val2) ? 1 : ((val1 < val2) ? -1 : (val1 == val2) ? 0 : NaN); + } + if (typeof val1 === 'boolean') { + return val1 - val2; + } + if (val1 instanceof Date) { + var result = val1 - val2; + return result; + } + // Return NaN if we don't know how to compare + return (val1 == val2) ? 0 : NaN; + } + + function testInEquality(example, val) { + if ('gt' in example) { + return compare(val, example.gt) > 0; + } + if ('gte' in example) { + return compare(val, example.gte) >= 0; + } + if ('lt' in example) { + return compare(val, example.lt) < 0; + } + if ('lte' in example) { + return compare(val, example.lte) <= 0; + } + return false; + } +} + +Memory.prototype.destroyAll = function destroyAll(model, where, options, callback) { + var cache = this.collection(model); + var filter = null; + var count = 0; + if (where) { + filter = applyFilter({where: where}); + Object.keys(cache).forEach(function (id) { + if (!filter || filter(this.fromDb(model, cache[id]))) { + count++; + delete cache[id]; + } + }.bind(this)); + } else { + count = Object.keys(cache).length; + this.collection(model, {}); + } + this.saveToFile({ count: count }, callback); +}; + +Memory.prototype.count = function count(model, where, options, callback) { + var cache = this.collection(model); + var data = Object.keys(cache); + if (where) { + var filter = {where: where}; + data = data.map(function (id) { + return this.fromDb(model, cache[id]); + }.bind(this)); + data = data.filter(applyFilter(filter)); + } + process.nextTick(function () { + callback(null, data.length); + }); +}; + +Memory.prototype.update = + Memory.prototype.updateAll = function updateAll(model, where, data, options, cb) { + var self = this; + var cache = this.collection(model); + var filter = null; + where = where || {}; + filter = applyFilter({where: where}); + + var ids = Object.keys(cache); + var count = 0; + async.each(ids, function (id, done) { + var inst = self.fromDb(model, cache[id]); + if (!filter || filter(inst)) { + count++; + // The id value from the cache is string + // Get the real id from the inst + id = self.getIdValue(model, inst); + self.updateAttributes(model, id, data, options, done); + } else { + process.nextTick(done); + } + }, function (err) { + if (err) return cb(err); + self.saveToFile({count: count}, cb); + }); + }; + +Memory.prototype.updateAttributes = function updateAttributes(model, id, data, options, cb) { + if (!id) { + var err = new Error('You must provide an id when updating attributes!'); + if (cb) { + return cb(err); + } else { + throw err; + } + } + + // Do not modify the data object passed in arguments + data = Object.create(data); + + this.setIdValue(model, data, id); + + var cachedModels = this.collection(model); + var modelData = cachedModels && this.collection(model)[id]; + + if (modelData) { + this.save(model, data, options, cb); + } else { + cb(new Error('Could not update attributes. Object with id ' + id + ' does not exist!')); + } +}; + +Memory.prototype.transaction = function () { + return new Memory(this); +}; + +Memory.prototype.exec = function (callback) { + this.onTransactionExec(); + setTimeout(callback, 50); +}; + +Memory.prototype.buildNearFilter = function (filter) { + // noop +} + +Memory.prototype.automigrate = function (models, cb) { + var self = this; + + if ((!cb) && ('function' === typeof models)) { + cb = models; + models = undefined; + } + // First argument is a model name + if ('string' === typeof models) { + models = [models]; + } + + models = models || Object.keys(self._models); + if (models.length === 0) { + return process.nextTick(cb); + } + + var invalidModels = models.filter(function(m) { + return !(m in self._models); + }); + + if (invalidModels.length) { + return process.nextTick(function() { + cb(new Error('Cannot migrate models not attached to this datasource: ' + + invalidModels.join(' '))); + }); + } + + models.forEach(function(m) { + self.initCollection(m); + }); + if (cb) process.nextTick(cb); +} + +function merge(base, update) { + if (!base) { + return update; + } + // We cannot use Object.keys(update) if the update is an instance of the model + // class as the properties are defined at the ModelClass.prototype level + for(var key in update) { + var val = update[key]; + if(typeof val === 'function') { + continue; // Skip methods + } + base[key] = val; + } + return base; +} diff --git a/lib/connectors/transient.js b/lib/connectors/transient.js new file mode 100644 index 0000000..0dc4940 --- /dev/null +++ b/lib/connectors/transient.js @@ -0,0 +1,144 @@ +var util = require('util'); +var Connector = require('../..').Connector; +var crypto = require('crypto'); + +/** + * Initialize the Transient connector against the given data source + * + * @param {DataSource} dataSource The loopback-datasource-juggler dataSource + * @param {Function} [callback] The callback function + */ +exports.initialize = function initializeDataSource(dataSource, callback) { + dataSource.connector = new Transient(null, dataSource.settings); + dataSource.connector.connect(callback); +}; + +exports.Transient = Transient; + +function Transient(m, settings) { + settings = settings || {}; + if (typeof settings.generateId === 'function') { + this.generateId = settings.generateId.bind(this); + } + this.defaultIdType = settings.defaultIdType || String; + if (m instanceof Transient) { + this.isTransaction = true; + this.constructor.super_.call(this, 'transient', settings); + this._models = m._models; + } else { + this.isTransaction = false; + this.constructor.super_.call(this, 'transient', settings); + } +} + +util.inherits(Transient, Connector); + +Transient.prototype.getDefaultIdType = function() { + return this.defaultIdType; +}; + +Transient.prototype.getTypes = function() { + return ['db', 'nosql', 'transient']; +}; + +Transient.prototype.connect = function (callback) { + if (this.isTransaction) { + this.onTransactionExec = callback; + } else { + process.nextTick(callback); + } +}; + +Transient.prototype.generateId = function(model, data, idName) { + var idType; + var props = this._models[model].properties; + if (idName) idType = props[idName] && props[idName].type; + idType = idType || this.getDefaultIdType(); + if (idType === Number) { + return Math.floor(Math.random() * 10000); // max. 4 digits + } else { + return crypto.randomBytes(Math.ceil(24/2)) + .toString('hex') // convert to hexadecimal format + .slice(0, 24); // return required number of characters + } +}; + +Transient.prototype.exists = function exists(model, id, callback) { + process.nextTick(function () { callback(null, false); }.bind(this)); +}; + +Transient.prototype.find = function find(model, id, callback) { + process.nextTick(function () { callback(null, null); }.bind(this)); +}; + +Transient.prototype.all = function all(model, filter, callback) { + process.nextTick(function () { callback(null, []); }); +}; + +Transient.prototype.count = function count(model, callback, where) { + process.nextTick(function () { callback(null, 0); }); +}; + +Transient.prototype.create = function create(model, data, callback) { + var props = this._models[model].properties; + var idName = this.idName(model); + if (idName && props[idName]) { + var id = this.getIdValue(model, data) || this.generateId(model, data, idName); + id = (props[idName] && props[idName].type && props[idName].type(id)) || id; + this.setIdValue(model, data, id); + } + this.flush('create', id, callback); +}; + +Transient.prototype.save = function save(model, data, callback) { + this.flush('save', data, callback); +}; + +Transient.prototype.update = + Transient.prototype.updateAll = function updateAll(model, where, data, cb) { + var count = 0; + this.flush('update', {count: count}, cb); +}; + +Transient.prototype.updateAttributes = function updateAttributes(model, id, data, cb) { + if (!id) { + var err = new Error('You must provide an id when updating attributes!'); + if (cb) { + return cb(err); + } else { + throw err; + } + } + + this.setIdValue(model, data, id); + this.save(model, data, cb); +}; + +Transient.prototype.destroy = function destroy(model, id, callback) { + this.flush('destroy', null, callback); +}; + +Transient.prototype.destroyAll = function destroyAll(model, where, callback) { + if (!callback && 'function' === typeof where) { + callback = where; + where = undefined; + } + this.flush('destroyAll', null, callback); +}; + +/*! + * Flush the cache - noop. + * @param {Function} callback + */ +Transient.prototype.flush = function (action, result, callback) { + process.nextTick(function () { callback && callback(null, result); }); +}; + +Transient.prototype.transaction = function () { + return new Transient(this); +}; + +Transient.prototype.exec = function (callback) { + this.onTransactionExec(); + setTimeout(callback, 50); +}; diff --git a/lib/datasource.js b/lib/datasource.js new file mode 100644 index 0000000..cf3007d --- /dev/null +++ b/lib/datasource.js @@ -0,0 +1,2191 @@ +/*! + * Module dependencies + */ +var ModelBuilder = require('loopback-model/lib/model-builder.js').ModelBuilder; +var ModelDefinition = require('loopback-model/lib/model-definition'); +var RelationDefinition = require('loopback-model-persistence/lib/relation-definition'); +var OberserverMixin = require('loopback-model/lib/observer'); +var jutil = require('loopback-model/lib/jutil'); +var utils = require('loopback-model/lib/utils'); +var ModelBaseClass = require('loopback-model/lib/model'); +var DataAccessObject = require('loopback-model-persistence/lib/dao'); +var defineScope = require('loopback-model-persistence/lib/scope').defineScope; +var EventEmitter = require('events').EventEmitter; +var util = require('util'); +var assert = require('assert'); +var async = require('async'); +var traverse = require('traverse'); + +if (process.env.DEBUG === 'loopback') { + // For back-compatibility + process.env.DEBUG = 'loopback:*'; +} +var debug = require('debug')('loopback:datasource'); + +/*! + * Export public API + */ +exports.DataSource = DataSource; + +/*! + * Helpers + */ +var slice = Array.prototype.slice; + +/** + * LoopBack models can manipulate data via the DataSource object. + * Attaching a `DataSource` to a `Model` adds instance methods and static methods to the `Model`. + * + * Define a data source to persist model data. + * To create a DataSource programmatically, call `createDataSource()` on the LoopBack object; for example: + * ```js + * var oracle = loopback.createDataSource({ + * connector: 'oracle', + * host: '111.22.333.44', + * database: 'MYDB', + * username: 'username', + * password: 'password' + * }); + * ``` + * + * All classes in single dataSource share same the connector type and + * one database connection. + * + * For example, the following creates a DataSource, and waits for a connection callback. + * + * ``` + * var dataSource = new DataSource('mysql', { database: 'myapp_test' }); + * dataSource.define(...); + * dataSource.on('connected', function () { + * // work with database + * }); + * ``` + * @class DataSource + * @param {String} [name] Optional name for datasource. + * @options {Object} settings Database-specific settings to establish connection (settings depend on specific connector). + * The table below lists a typical set for a relational database. + * @property {String} connector Database connector to use. For any supported connector, can be any of: + * + * - The connector module from `require(connectorName)`. + * - The full name of the connector module, such as 'loopback-connector-oracle'. + * - The short name of the connector module, such as 'oracle'. + * - A local module under `./connectors/` folder. + * @property {String} host Database server host name. + * @property {String} port Database server port number. + * @property {String} username Database user name. + * @property {String} password Database password. + * @property {String} database Name of the database to use. + * @property {Boolean} debug Display debugging information. Default is false. + */ +function DataSource(name, settings, modelBuilder) { + if (!(this instanceof DataSource)) { + return new DataSource(name, settings); + } + + // Check if the settings object is passed as the first argument + if (typeof name === 'object' && settings === undefined) { + settings = name; + name = undefined; + } + + // Check if the first argument is a URL + if (typeof name === 'string' && name.indexOf('://') !== -1) { + name = utils.parseSettings(name); + } + + // Check if the settings is in the form of URL string + if (typeof settings === 'string' && settings.indexOf('://') !== -1) { + settings = utils.parseSettings(settings); + } + + this.modelBuilder = modelBuilder || new ModelBuilder(); + this.models = this.modelBuilder.models; + this.definitions = this.modelBuilder.definitions; + + // operation metadata + // Initialize it before calling setup as the connector might register operations + this._operations = {}; + + this.setup(name, settings); + + this._setupConnector(); + + // connector + var connector = this.connector; + + // DataAccessObject - connector defined or supply the default + var dao = (connector && connector.DataAccessObject) || this.constructor.DataAccessObject; + this.DataAccessObject = function () { + }; + + // define DataAccessObject methods + Object.keys(dao).forEach(function (name) { + var fn = dao[name]; + this.DataAccessObject[name] = fn; + + if (typeof fn === 'function') { + this.defineOperation(name, { + accepts: fn.accepts, + 'returns': fn.returns, + http: fn.http, + remoteEnabled: fn.shared ? true : false, + scope: this.DataAccessObject, + fnName: name + }); + } + }.bind(this)); + + // define DataAccessObject.prototype methods + Object.keys(dao.prototype).forEach(function (name) { + var fn = dao.prototype[name]; + this.DataAccessObject.prototype[name] = fn; + if (typeof fn === 'function') { + this.defineOperation(name, { + prototype: true, + accepts: fn.accepts, + 'returns': fn.returns, + http: fn.http, + remoteEnabled: fn.shared ? true : false, + scope: this.DataAccessObject.prototype, + fnName: name + }); + } + }.bind(this)); + +} + +util.inherits(DataSource, EventEmitter); + +// allow child classes to supply a data access object +DataSource.DataAccessObject = DataAccessObject; + +/** + * Set up the connector instance for backward compatibility with JugglingDB schema/adapter + * @private + */ +DataSource.prototype._setupConnector = function () { + this.connector = this.connector || this.adapter; // The legacy JugglingDB adapter will set up `adapter` property + this.adapter = this.connector; // Keep the adapter as an alias to connector + if (this.connector) { + if (!this.connector.dataSource) { + // Set up the dataSource if the connector doesn't do so + this.connector.dataSource = this; + } + var dataSource = this; + this.connector.log = function (query, start) { + dataSource.log(query, start); + }; + + this.connector.logger = function (query) { + var t1 = Date.now(); + var log = this.log; + return function (q) { + log(q || query, t1); + }; + }; + // Configure the connector instance to mix in observer functions + jutil.mixin(this.connector, OberserverMixin); + } +}; + +// List possible connector module names +function connectorModuleNames(name) { + var names = []; // Check the name as is + if (!name.match(/^\//)) { + names.push('./connectors/' + name); // Check built-in connectors + if (name.indexOf('loopback-connector-') !== 0) { + names.push('loopback-connector-' + name); // Try loopback-connector- + } + } + // Only try the short name if the connector is not from StrongLoop + if (['mongodb', 'oracle', 'mysql', 'postgresql', 'mssql', 'rest', 'soap'] + .indexOf(name) === -1) { + names.push(name); + } + return names; +} + +// testable with DI +function tryModules(names, loader) { + var mod; + loader = loader || require; + for (var m = 0; m < names.length; m++) { + try { + mod = loader(names[m]); + } catch (e) { + /* ignore */ + } + if (mod) { + break; + } + } + return mod; +} + +/*! + * Resolve a connector by name + * @param name The connector name + * @returns {*} + * @private + */ +DataSource._resolveConnector = function (name, loader) { + var names = connectorModuleNames(name); + var connector = tryModules(names, loader); + var error = null; + if (!connector) { + error = util.format('\nWARNING: LoopBack connector "%s" is not installed ' + + 'as any of the following modules:\n\n %s\n\nTo fix, run:\n\n npm install %s\n', + name, names.join('\n'), names[names.length - 1]); + } + return { + connector: connector, + error: error + }; +}; + +/** + * Set up the data source + * @param {String} name The name + * @param {Object} settings The settings + * @returns {*} + * @private + */ +DataSource.prototype.setup = function (name, settings) { + var dataSource = this; + var connector; + + // support single settings object + if (name && typeof name === 'object' && !settings) { + settings = name; + name = undefined; + } + + if (typeof settings === 'object') { + if (settings.initialize) { + connector = settings; + } else if (settings.connector) { + connector = settings.connector; + } else if (settings.adapter) { + connector = settings.adapter; + } + } + + // just save everything we get + this.settings = settings || {}; + + this.settings.debug = this.settings.debug || debug.enabled; + + if (this.settings.debug) { + debug('Settings: %j', this.settings); + } + + // Disconnected by default + this.connected = false; + this.connecting = false; + + if (typeof connector === 'string') { + name = connector; + connector = undefined; + } + name = name || (connector && connector.name); + this.name = name; + + if (name && !connector) { + if (typeof name === 'object') { + // The first argument might be the connector itself + connector = name; + this.name = connector.name; + } else { + // The connector has not been resolved + var result = DataSource._resolveConnector(name); + connector = result.connector; + if (!connector) { + console.error(result.error); + this.emit('error', new Error(result.error)); + return; + } + } + } + + if (connector) { + var postInit = function postInit(err, result) { + + this._setupConnector(); + // we have an connector now? + if (!this.connector) { + throw new Error('Connector is not defined correctly: it should create `connector` member of dataSource'); + } + this.connected = !err; // Connected now + if (this.connected) { + this.emit('connected'); + } else { + // The connection fails, let's report it and hope it will be recovered in the next call + console.error('Connection fails: ', err, '\nIt will be retried for the next request.'); + this.emit('error', err); + this.connecting = false; + } + + }.bind(this); + + if ('function' === typeof connector.initialize) { + // Call the async initialize method + connector.initialize(this, postInit); + } else if ('function' === typeof connector) { + // Use the connector constructor directly + this.connector = new connector(this.settings); + postInit(); + } + } + + dataSource.connect = function (cb) { + var dataSource = this; + if (dataSource.connected || dataSource.connecting) { + process.nextTick(function () { + cb && cb(); + }); + return; + } + dataSource.connecting = true; + if (dataSource.connector.connect) { + dataSource.connector.connect(function (err, result) { + if (!err) { + dataSource.connected = true; + dataSource.connecting = false; + dataSource.emit('connected'); + } else { + dataSource.connected = false; + dataSource.connecting = false; + dataSource.emit('error', err); + } + cb && cb(err, result); + }); + } else { + process.nextTick(function () { + dataSource.connected = true; + dataSource.connecting = false; + dataSource.emit('connected'); + cb && cb(); + }); + } + }; +}; + +function isModelClass(cls) { + if (!cls) { + return false; + } + return cls.prototype instanceof ModelBaseClass; +} + +DataSource.relationTypes = Object.keys(RelationDefinition.RelationTypes); + +function isModelDataSourceAttached(model) { + return model && (!model.settings.unresolved) && (model.dataSource instanceof DataSource); +} + +/*! + * Define scopes for the model class from the scopes object + * @param modelClass + * @param scopes + */ +DataSource.prototype.defineScopes = function (modelClass, scopes) { + if (scopes) { + for (var s in scopes) { + defineScope(modelClass, modelClass, s, scopes[s], {}, scopes[s].options); + } + } +}; + +/*! + * Define relations for the model class from the relations object + * @param modelClass + * @param relations + */ +DataSource.prototype.defineRelations = function (modelClass, relations) { + var self = this; + + // Create a function for the closure in the loop + var createListener = function (name, relation, targetModel, throughModel) { + if (!isModelDataSourceAttached(targetModel)) { + targetModel.once('dataAccessConfigured', function (model) { + // Check if the through model doesn't exist or resolved + if (!throughModel || isModelDataSourceAttached(throughModel)) { + // The target model is resolved + var params = traverse(relation).clone(); + params.as = name; + params.model = model; + if (throughModel) { + params.through = throughModel; + } + modelClass[relation.type].call(modelClass, name, params); + } + }); + + } + if (throughModel && !isModelDataSourceAttached(throughModel)) { + // Set up a listener to the through model + throughModel.once('dataAccessConfigured', function (model) { + if (isModelDataSourceAttached(targetModel)) { + // The target model is resolved + var params = traverse(relation).clone(); + params.as = name; + params.model = targetModel; + params.through = model; + modelClass[relation.type].call(modelClass, name, params); + } + }); + } + }; + + // Set up the relations + if (relations) { + Object.keys(relations).forEach(function (rn) { + var r = relations[rn]; + assert(DataSource.relationTypes.indexOf(r.type) !== -1, "Invalid relation type: " + r.type); + var targetModel, polymorphicName; + + if (r.polymorphic && r.type !== 'belongsTo' && !r.model) { + throw new Error('No model specified for polymorphic ' + r.type + ': ' + rn); + } + + if (r.polymorphic) { + polymorphicName = typeof r.model === 'string' ? r.model : rn; + if (typeof r.polymorphic === 'string') { + polymorphicName = r.polymorphic; + } else if (typeof r.polymorphic === 'object' && typeof r.polymorphic.as === 'string') { + polymorphicName = r.polymorphic.as; + } + } + + if (r.model) { + targetModel = isModelClass(r.model) ? r.model : self.getModel(r.model, true); + } + + var throughModel = null; + if (r.through) { + throughModel = isModelClass(r.through) ? r.through : self.getModel(r.through, true); + } + + if ((targetModel && !isModelDataSourceAttached(targetModel)) + || (throughModel && !isModelDataSourceAttached(throughModel))) { + // Create a listener to defer the relation set up + createListener(rn, r, targetModel, throughModel); + } else { + // The target model is resolved + var params = traverse(r).clone(); + params.as = rn; + params.model = polymorphicName || targetModel; + if (throughModel) { + params.through = throughModel; + } + modelClass[r.type].call(modelClass, rn, params); + } + }); + } +}; + +/*! + * Set up the data access functions from the data source + * @param {Model} modelClass The model class + * @param {Object} settings The settings object + */ +DataSource.prototype.setupDataAccess = function (modelClass, settings) { + if (this.connector) { + // Check if the id property should be generated + var idName = modelClass.definition.idName(); + var idProp = modelClass.definition.rawProperties[idName]; + if (idProp && idProp.generated && this.connector.getDefaultIdType) { + // Set the default id type from connector's ability + var idType = this.connector.getDefaultIdType() || String; + idProp.type = idType; + modelClass.definition.rawProperties[idName].type = idType; + modelClass.definition.properties[idName].type = idType; + if (settings.forceId) { + modelClass.validatesAbsenceOf(idName, {if: 'isNewRecord'}); + } + } + if (this.connector.define) { + // pass control to connector + this.connector.define({ + model: modelClass, + properties: modelClass.definition.properties, + settings: settings + }); + } + } + + // add data access objects + this.mixin(modelClass); + + // define relations from LDL (options.relations) + var relations = settings.relationships || settings.relations; + this.defineRelations(modelClass, relations); + + // Emit the dataAccessConfigured event to indicate all the methods for data + // access have been mixed into the model class + modelClass.emit('dataAccessConfigured', modelClass); + + // define scopes from LDL (options.relations) + var scopes = settings.scopes || {}; + this.defineScopes(modelClass, scopes); + +}; + +/** + * Define a model class. Returns newly created model object. + * The first (String) argument specifying the model name is required. + * You can provide one or two JSON object arguments, to provide configuration options. + * See [Model definition reference](http://docs.strongloop.com/display/DOC/Model+definition+reference) for details. + * + * Simple example: + * ``` + * var User = dataSource.createModel('User', { + * email: String, + * password: String, + * birthDate: Date, + * activated: Boolean + * }); + * ``` + * More advanced example + * ``` + * var User = dataSource.createModel('User', { + * email: { type: String, limit: 150, index: true }, + * password: { type: String, limit: 50 }, + * birthDate: Date, + * registrationDate: {type: Date, default: function () { return new Date }}, + * activated: { type: Boolean, default: false } + * }); + * ``` + * You can also define an ACL when you create a new data source with the `DataSource.create()` method. For example: + * + * ```js + * var Customer = ds.createModel('Customer', { + * name: { + * type: String, + * acls: [ + * {principalType: ACL.USER, principalId: 'u001', accessType: ACL.WRITE, permission: ACL.DENY}, + * {principalType: ACL.USER, principalId: 'u001', accessType: ACL.ALL, permission: ACL.ALLOW} + * ] + * } + * }, { + * acls: [ + * {principalType: ACL.USER, principalId: 'u001', accessType: ACL.ALL, permission: ACL.ALLOW} + * ] + * }); + * ``` + * + * @param {String} className Name of the model to create. + * @param {Object} properties Hash of model properties in format `{property: Type, property2: Type2, ...}` or `{property: {type: Type}, property2: {type: Type2}, ...}` + * @options {Object} properties Other configuration options. This corresponds to the options key in the config object. + * + */ + +DataSource.prototype.createModel = DataSource.prototype.define = function defineClass(className, properties, settings) { + var args = slice.call(arguments); + + if (!className) { + throw new Error('Class name required'); + } + if (args.length === 1) { + properties = {}; + args.push(properties); + } + if (args.length === 2) { + settings = {}; + args.push(settings); + } + + properties = properties || {}; + settings = settings || {}; + + if (this.isRelational()) { + // Set the strict mode to be true for relational DBs by default + if (settings.strict === undefined || settings.strict === null) { + settings.strict = true; + } + if (settings.strict === false) { + settings.strict = 'throw'; + } + } + + var modelClass = this.modelBuilder.define(className, properties, settings); + modelClass.dataSource = this; + + if (settings.unresolved) { + return modelClass; + } + + this.setupDataAccess(modelClass, settings); + modelClass.emit('dataSourceAttached', modelClass); + + return modelClass; +}; + +/** + * Mixin DataAccessObject methods. + * + * @param {Function} ModelCtor The model constructor + * @private + */ + +DataSource.prototype.mixin = function (ModelCtor) { + var ops = this.operations(); + var DAO = this.DataAccessObject; + + // mixin DAO + jutil.mixin(ModelCtor, DAO, {proxyFunctions: true, override: true}); + + // decorate operations as alias functions + Object.keys(ops).forEach(function (name) { + var op = ops[name]; + var scope; + + if (op.enabled) { + scope = op.prototype ? ModelCtor.prototype : ModelCtor; + // var sfn = scope[name] = function () { + // op.scope[op.fnName].apply(self, arguments); + // } + Object.keys(op) + .filter(function (key) { + // filter out the following keys + return ~[ + 'scope', + 'fnName', + 'prototype' + ].indexOf(key); + }) + .forEach(function (key) { + if (typeof op[key] !== 'undefined') { + op.scope[op.fnName][key] = op[key]; + } + }); + } + }); +}; + +/** + * See ModelBuilder.getModel + */ +DataSource.prototype.getModel = function (name, forceCreate) { + return this.modelBuilder.getModel(name, forceCreate); +}; + +/** + * See ModelBuilder.getModelDefinition + */ +DataSource.prototype.getModelDefinition = function (name) { + return this.modelBuilder.getModelDefinition(name); +}; + +/** + * Get the data source types + * @returns {String[]} The data source type, such as ['db', 'nosql', 'mongodb'], + * ['rest'], or ['db', 'rdbms', 'mysql'] + */ +DataSource.prototype.getTypes = function () { + var getTypes = this.connector && this.connector.getTypes; + var types = getTypes && getTypes() || []; + if (typeof types === 'string') { + types = types.split(/[\s,\/]+/); + } + return types; +}; + +/** + * Check the data source supports the specified types. + * @param {String} types Type name or an array of type names. Can also be array of Strings. + * @returns {Boolean} true if all types are supported by the data source + */ +DataSource.prototype.supportTypes = function (types) { + var supportedTypes = this.getTypes(); + if (Array.isArray(types)) { + // Check each of the types + for (var i = 0; i < types.length; i++) { + if (supportedTypes.indexOf(types[i]) === -1) { + // Not supported + return false; + } + } + return true; + } else { + // The types is a string + return supportedTypes.indexOf(types) !== -1; + } +}; + +/** + * Attach an existing model to a data source. + * + * @param {Function} modelClass The model constructor + */ + +DataSource.prototype.attach = function (modelClass) { + if (modelClass.dataSource === this) { + // Already attached to the data source + return modelClass; + } + + if (modelClass.modelBuilder !== this.modelBuilder) { + this.modelBuilder.definitions[modelClass.modelName] = modelClass.definition; + this.modelBuilder.models[modelClass.modelName] = modelClass; + // reset the modelBuilder + modelClass.modelBuilder = this.modelBuilder; + } + + // redefine the dataSource + modelClass.dataSource = this; + + this.setupDataAccess(modelClass, modelClass.settings); + modelClass.emit('dataSourceAttached', modelClass); + return modelClass; + +}; + +/** + * Define single property named `prop` on `model` + * + * @param {String} model Name of model + * @param {String} prop Name of property + * @param {Object} params Property settings + */ +DataSource.prototype.defineProperty = function (model, prop, params) { + this.modelBuilder.defineProperty(model, prop, params); + + var resolvedProp = this.getModelDefinition(model).properties[prop]; + if (this.connector && this.connector.defineProperty) { + this.connector.defineProperty(model, prop, resolvedProp); + } +}; + +/** + * Drop each model table and re-create. + * This method applies only to database connectors. For MongoDB, it drops and creates indexes. + * + * **WARNING**: Calling this function deletes all data! Use `autoupdate()` to preserve data. + * + * @param {String} model Model to migrate. If not present, apply to all models. Can also be an array of Strings. + * @param {Function} [callback] Callback function. Optional. + * + + */ +DataSource.prototype.automigrate = function (models, cb) { + this.freeze(); + + if ((!cb) && ('function' === typeof models)) { + cb = models; + models = undefined; + } + + cb = cb || utils.createPromiseCallback(); + + if (!this.connector.automigrate) { + // NOOP + process.nextTick(cb); + return cb.promise; + } + + // First argument is a model name + if ('string' === typeof models) { + models = [models]; + } + + var attachedModels = this.connector._models; + + if (attachedModels && typeof attachedModels === 'object') { + models = models || Object.keys(attachedModels); + + if (models.length === 0) { + process.nextTick(cb); + return cb.promise; + } + + var invalidModels = models.filter(function (m) { + return !(m in attachedModels); + }); + + if (invalidModels.length) { + process.nextTick(function () { + cb(new Error('Cannot migrate models not attached to this datasource: ' + + invalidModels.join(' '))); + }); + return cb.promise; + } + } + + this.connector.automigrate(models, cb); + return cb.promise; +}; + +/** + * Update existing database tables. + * This method applies only to database connectors. + * + * @param {String} model Model to migrate. If not present, apply to all models. Can also be an array of Strings. + * @param {Function} [cb] The callback function + */ +DataSource.prototype.autoupdate = function (models, cb) { + this.freeze(); + + if ((!cb) && ('function' === typeof models)) { + cb = models; + models = undefined; + } + + cb = cb || utils.createPromiseCallback(); + + if (!this.connector.autoupdate) { + // NOOP + process.nextTick(cb); + return cb.promise; + } + + // First argument is a model name + if ('string' === typeof models) { + models = [models]; + } + + var attachedModels = this.connector._models; + + if (attachedModels && typeof attachedModels === 'object') { + models = models || Object.keys(attachedModels); + + if (models.length === 0) { + process.nextTick(cb); + return cb.promise; + } + + var invalidModels = models.filter(function (m) { + return !(m in attachedModels); + }); + + if (invalidModels.length) { + process.nextTick(function () { + cb(new Error('Cannot migrate models not attached to this datasource: ' + + invalidModels.join(' '))); + }); + return cb.promise; + } + } + + this.connector.autoupdate(models, cb); + return cb.promise; +}; + +/** + * Discover existing database tables. + * This method returns an array of model objects, including {type, name, onwer} + * + * @param {Object} options The options + * @param {Function} Callback function. Optional. + * @options {Object} options Discovery options. See below. + * @property {Boolean} all If true, discover all models; if false, discover only models owned by the current user. + * @property {Boolean} views If true, nclude views; if false, only tables. + * @property {Number} limit Page size + * @property {Number} offset Starting index + * + */ +DataSource.prototype.discoverModelDefinitions = function (options, cb) { + this.freeze(); + + if (cb === undefined && typeof options === 'function') { + cb = options; + options = {}; + } + options = options || {}; + cb = cb || utils.createPromiseCallback(); + + if (this.connector.discoverModelDefinitions) { + this.connector.discoverModelDefinitions(options, cb); + } else if (cb) { + process.nextTick(cb); + } + return cb.promise; +}; + +/** + * The synchronous version of discoverModelDefinitions. + * @options {Object} options The options + * @property {Boolean} all If true, discover all models; if false, discover only models owned by the current user. + * @property {Boolean} views If true, nclude views; if false, only tables. + * @property {Number} limit Page size + * @property {Number} offset Starting index + * @returns {*} + */ +DataSource.prototype.discoverModelDefinitionsSync = function (options) { + this.freeze(); + if (this.connector.discoverModelDefinitionsSync) { + return this.connector.discoverModelDefinitionsSync(options); + } + return null; +}; + +/** + * Discover properties for a given model. + * + * Callback function return value is an object that can have the following properties: + * + *| Key | Type | Description | + *|-----|------|-------------| + *|owner | String | Database owner or schema| + *|tableName | String | Table/view name| + *|columnName | String | Column name| + *|dataType | String | Data type| + *|dataLength | Number | Data length| + *|dataPrecision | Number | Numeric data precision| + *|dataScale |Number | Numeric data scale| + *|nullable |Boolean | If true, then the data can be null| + * + * @param {String} modelName The table/view name + * @options {Object} options The options + * @property {String} owner|schema The database owner or schema + * @param {Function} cb Callback function. Optional + * + */ +DataSource.prototype.discoverModelProperties = function (modelName, options, cb) { + this.freeze(); + + if (cb === undefined && typeof options === 'function') { + cb = options; + options = {}; + } + options = options || {}; + cb = cb || utils.createPromiseCallback(); + + if (this.connector.discoverModelProperties) { + this.connector.discoverModelProperties(modelName, options, cb); + } else if (cb) { + process.nextTick(cb); + } + return cb.promise; +}; + +/** + * The synchronous version of discoverModelProperties + * @param {String} modelName The table/view name + * @param {Object} options The options + * @returns {*} + */ +DataSource.prototype.discoverModelPropertiesSync = function (modelName, options) { + this.freeze(); + if (this.connector.discoverModelPropertiesSync) { + return this.connector.discoverModelPropertiesSync(modelName, options); + } + return null; +}; + +/** + * Discover primary keys for a given owner/modelName. + * Callback function return value is an object that can have the following properties: + * + *| Key | Type | Description | + *|-----|------|-------------| + *| owner |String | Table schema or owner (may be null). Owner defaults to current user. + *| tableName |String| Table name + *| columnName |String| Column name + *| keySeq |Number| Sequence number within primary key (1 indicates the first column in the primary key; 2 indicates the second column in the primary key). + *| pkName |String| Primary key name (may be null) + * + * @param {String} modelName The model name + * @options {Object} options The options + * @property {String} owner|schema The database owner or schema + * @param {Function} [cb] The callback function + */ +DataSource.prototype.discoverPrimaryKeys = function (modelName, options, cb) { + this.freeze(); + + if (cb === undefined && typeof options === 'function') { + cb = options; + options = {}; + } + options = options || {}; + cb = cb || utils.createPromiseCallback(); + + if (this.connector.discoverPrimaryKeys) { + this.connector.discoverPrimaryKeys(modelName, options, cb); + } else if (cb) { + process.nextTick(cb); + } + return cb.promise; +}; + +/** + * The synchronous version of discoverPrimaryKeys + * @param {String} modelName The model name + * @options {Object} options The options + * @property {String} owner|schema The database owner orschema + * @returns {*} + */ +DataSource.prototype.discoverPrimaryKeysSync = function (modelName, options) { + this.freeze(); + if (this.connector.discoverPrimaryKeysSync) { + return this.connector.discoverPrimaryKeysSync(modelName, options); + } + return null; +}; + +/** + * Discover foreign keys for a given owner/modelName + * + * Callback function return value is an object that can have the following properties: + * + *| Key | Type | Description | + *|-----|------|-------------| + *|fkOwner |String | Foreign key table schema (may be null) + *|fkName |String | Foreign key name (may be null) + *|fkTableName |String | Foreign key table name + *|fkColumnName |String | Foreign key column name + *|keySeq |Number | Sequence number within a foreign key( a value of 1 represents the first column of the foreign key, a value of 2 would represent the second column within the foreign key). + *|pkOwner |String | Primary key table schema being imported (may be null) + *|pkName |String | Primary key name (may be null) + *|pkTableName |String | Primary key table name being imported + *|pkColumnName |String | Primary key column name being imported + * + * @param {String} modelName The model name + * @options {Object} options The options + * @property {String} owner|schema The database owner or schema + * @param {Function} [cb] The callback function + * + */ +DataSource.prototype.discoverForeignKeys = function (modelName, options, cb) { + this.freeze(); + + if (cb === undefined && typeof options === 'function') { + cb = options; + options = {}; + } + options = options || {}; + cb = cb || utils.createPromiseCallback(); + + if (this.connector.discoverForeignKeys) { + this.connector.discoverForeignKeys(modelName, options, cb); + } else if (cb) { + process.nextTick(cb); + } + return cb.promise; +}; + +/** + * The synchronous version of discoverForeignKeys + * + * @param {String} modelName The model name + * @param {Object} options The options + * @returns {*} + */ +DataSource.prototype.discoverForeignKeysSync = function (modelName, options) { + this.freeze(); + if (this.connector.discoverForeignKeysSync) { + return this.connector.discoverForeignKeysSync(modelName, options); + } + return null; +}; + +/** + * Retrieves a description of the foreign key columns that reference the given table's primary key columns + * (the foreign keys exported by a table), ordered by fkTableOwner, fkTableName, and keySeq. + * + * Callback function return value is an object that can have the following properties: + * + *| Key | Type | Description | + *|-----|------|-------------| + *|fkOwner |String | Foreign key table schema (may be null) + *|fkName |String | Foreign key name (may be null) + *|fkTableName |String | Foreign key table name + *|fkColumnName |String | Foreign key column name + *|keySeq |Number | Sequence number within a foreign key( a value of 1 represents the first column of the foreign key, a value of 2 would represent the second column within the foreign key). + *|pkOwner |String | Primary key table schema being imported (may be null) + *|pkName |String | Primary key name (may be null) + *|pkTableName |String | Primary key table name being imported + *|pkColumnName |String | Primary key column name being imported + * + * @param {String} modelName The model name + * @options {Object} options The options + * @property {String} owner|schema The database owner or schema + * @param {Function} [cb] The callback function + */ +DataSource.prototype.discoverExportedForeignKeys = function (modelName, options, cb) { + this.freeze(); + + if (cb === undefined && typeof options === 'function') { + cb = options; + options = {}; + } + options = options || {}; + cb = cb || utils.createPromiseCallback(); + + if (this.connector.discoverExportedForeignKeys) { + this.connector.discoverExportedForeignKeys(modelName, options, cb); + } else if (cb) { + process.nextTick(cb); + } + return cb.promise; +}; + +/** + * The synchronous version of discoverExportedForeignKeys + * @param {String} modelName The model name + * @param {Object} options The options + * @returns {*} + */ +DataSource.prototype.discoverExportedForeignKeysSync = function (modelName, options) { + this.freeze(); + if (this.connector.discoverExportedForeignKeysSync) { + return this.connector.discoverExportedForeignKeysSync(modelName, options); + } + return null; +}; + +function capitalize(str) { + if (!str) { + return str; + } + return str.charAt(0).toUpperCase() + ((str.length > 1) ? str.slice(1).toLowerCase() : ''); +} + +function fromDBName(dbName, camelCase) { + if (!dbName) { + return dbName; + } + var parts = dbName.split(/-|_/); + parts[0] = camelCase ? parts[0].toLowerCase() : capitalize(parts[0]); + + for (var i = 1; i < parts.length; i++) { + parts[i] = capitalize(parts[i]); + } + return parts.join(''); +} + +/** + * Discover one schema from the given model without following the relations. + **Example schema from oracle connector:** + * + * ```js + * { + * "name": "Product", + * "options": { + * "idInjection": false, + * "oracle": { + * "schema": "BLACKPOOL", + * "table": "PRODUCT" + * } + * }, + * "properties": { + * "id": { + * "type": "String", + * "required": true, + * "length": 20, + * "id": 1, + * "oracle": { + * "columnName": "ID", + * "dataType": "VARCHAR2", + * "dataLength": 20, + * "nullable": "N" + * } + * }, + * "name": { + * "type": "String", + * "required": false, + * "length": 64, + * "oracle": { + * "columnName": "NAME", + * "dataType": "VARCHAR2", + * "dataLength": 64, + * "nullable": "Y" + * } + * }, + * ... + * "fireModes": { + * "type": "String", + * "required": false, + * "length": 64, + * "oracle": { + * "columnName": "FIRE_MODES", + * "dataType": "VARCHAR2", + * "dataLength": 64, + * "nullable": "Y" + * } + * } + * } + * } + * ``` + * + * @param {String} modelName The model name + * @param {Object} [options] The options + * @param {Function} [cb] The callback function + */ +DataSource.prototype.discoverSchema = function (modelName, options, cb) { + options = options || {}; + + if (!cb && 'function' === typeof options) { + cb = options; + options = {}; + } + options.visited = {}; + options.relations = false; + + cb = cb || utils.createPromiseCallback(); + + this.discoverSchemas(modelName, options, function (err, schemas) { + if (err) { + cb && cb(err, schemas); + return; + } + for (var s in schemas) { + cb && cb(null, schemas[s]); + return; + } + }); + return cb.promise; +}; + +/** + * Discover schema from a given modelName/view. + * + * @param {String} modelName The model name. + * @options {Object} [options] Options; see below. + * @property {String} owner|schema Database owner or schema name. + * @property {Boolean} relations True if relations (primary key/foreign key) are navigated; false otherwise. + * @property {Boolean} all True if all owners are included; false otherwise. + * @property {Boolean} views True if views are included; false otherwise. + * @param {Function} [cb] The callback function + */ +DataSource.prototype.discoverSchemas = function (modelName, options, cb) { + options = options || {}; + + if (!cb && 'function' === typeof options) { + cb = options; + options = {}; + } + + cb = cb || utils.createPromiseCallback(); + + var self = this; + var dbType = this.connector.name || this.name; + + var nameMapper; + if (options.nameMapper === null) { + // No mapping + nameMapper = function(type, name) { + return name; + }; + } else if (typeof options.nameMapper === 'function') { + // Custom name mapper + nameMapper = options.nameMapper; + } else { + // Default name mapper + nameMapper = function mapName(type, name) { + if (type === 'table' || type === 'model') { + return fromDBName(name, false); + } else { + return fromDBName(name, true); + } + }; + } + + if (this.connector.discoverSchemas) { + // Delegate to the connector implementation + this.connector.discoverSchemas(modelName, options, cb); + return cb.promise; + } + + var tasks = [ + this.discoverModelProperties.bind(this, modelName, options), + this.discoverPrimaryKeys.bind(this, modelName, options)]; + + var followingRelations = options.associations || options.relations; + if (followingRelations) { + tasks.push(this.discoverForeignKeys.bind(this, modelName, options)); + } + + async.parallel(tasks, function (err, results) { + + if (err) { + cb(err); + return cb.promise; + } + + var columns = results[0]; + if (!columns || columns.length === 0) { + cb(); + return cb.promise; + } + + // Handle primary keys + var primaryKeys = results[1] || []; + var pks = {}; + primaryKeys.forEach(function (pk) { + pks[pk.columnName] = pk.keySeq; + }); + + if (self.settings.debug) { + debug('Primary keys: ', pks); + } + + var schema = { + name: nameMapper('table', modelName), + options: { + idInjection: false // DO NOT add id property + }, + properties: {} + }; + + schema.options[dbType] = { + schema: columns[0].owner, + table: modelName + }; + + columns.forEach(function (item) { + var i = item; + + var propName = nameMapper('column', item.columnName); + schema.properties[propName] = { + type: item.type, + required: (item.nullable === 'N' || item.nullable === 'NO' + || item.nullable === 0 || item.nullable === false), + length: item.dataLength, + precision: item.dataPrecision, + scale: item.dataScale + }; + + if (pks[item.columnName]) { + schema.properties[propName].id = pks[item.columnName]; + } + schema.properties[propName][dbType] = { + columnName: i.columnName, + dataType: i.dataType, + dataLength: i.dataLength, + dataPrecision: item.dataPrecision, + dataScale: item.dataScale, + nullable: i.nullable + }; + }); + + // Add current modelName to the visited tables + options.visited = options.visited || {}; + var schemaKey = columns[0].owner + '.' + modelName; + if (!options.visited.hasOwnProperty(schemaKey)) { + if (self.settings.debug) { + debug('Adding schema for ' + schemaKey); + } + options.visited[schemaKey] = schema; + } + + var otherTables = {}; + if (followingRelations) { + // Handle foreign keys + var fks = {}; + var foreignKeys = results[2] || []; + foreignKeys.forEach(function (fk) { + var fkInfo = { + keySeq: fk.keySeq, + owner: fk.pkOwner, + tableName: fk.pkTableName, + columnName: fk.pkColumnName + }; + if (fks[fk.fkName]) { + fks[fk.fkName].push(fkInfo); + } else { + fks[fk.fkName] = [fkInfo]; + } + }); + + if (self.settings.debug) { + debug('Foreign keys: ', fks); + } + + schema.options.relations = {}; + foreignKeys.forEach(function (fk) { + var propName = nameMapper('column', fk.pkTableName); + schema.options.relations[propName] = { + model: nameMapper('table', fk.pkTableName), + type: 'belongsTo', + foreignKey: nameMapper('column', fk.fkColumnName) + }; + + var key = fk.pkOwner + '.' + fk.pkTableName; + if (!options.visited.hasOwnProperty(key) && !otherTables.hasOwnProperty(key)) { + otherTables[key] = {owner: fk.pkOwner, tableName: fk.pkTableName}; + } + }); + } + + if (Object.keys(otherTables).length === 0) { + cb(null, options.visited); + } else { + var moreTasks = []; + for (var t in otherTables) { + if (self.settings.debug) { + debug('Discovering related schema for ' + schemaKey); + } + var newOptions = {}; + for (var key in options) { + newOptions[key] = options[key]; + } + newOptions.owner = otherTables[t].owner; + + moreTasks.push(DataSource.prototype.discoverSchemas.bind(self, otherTables[t].tableName, newOptions)); + } + async.parallel(moreTasks, function (err, results) { + var result = results && results[0]; + cb(err, result); + }); + } + }); + return cb.promise; +}; + +/** + * Discover schema from a given table/view synchronously + * + * @param {String} modelName The model name + * @options {Object} [options] Options; see below. + * @property {String} owner|schema Database owner or schema name. + * @property {Boolean} relations True if relations (primary key/foreign key) are navigated; false otherwise. + * @property {Boolean} all True if all owners are included; false otherwise. + * @property {Boolean} views True if views are included; false otherwise. + */ +DataSource.prototype.discoverSchemasSync = function (modelName, options) { + var self = this; + var dbType = this.name || this.connector.name; + + var columns = this.discoverModelPropertiesSync(modelName, options); + if (!columns || columns.length === 0) { + return []; + } + + var nameMapper = options.nameMapper || function mapName(type, name) { + if (type === 'table' || type === 'model') { + return fromDBName(name, false); + } else { + return fromDBName(name, true); + } + }; + + // Handle primary keys + var primaryKeys = this.discoverPrimaryKeysSync(modelName, options); + var pks = {}; + primaryKeys.forEach(function (pk) { + pks[pk.columnName] = pk.keySeq; + }); + + if (self.settings.debug) { + debug('Primary keys: ', pks); + } + + var schema = { + name: nameMapper('table', modelName), + options: { + idInjection: false // DO NOT add id property + }, + properties: {} + }; + + schema.options[dbType] = { + schema: columns.length > 0 && columns[0].owner, + table: modelName + }; + + columns.forEach(function (item) { + var i = item; + + var propName = nameMapper('column', item.columnName); + schema.properties[propName] = { + type: item.type, + required: (item.nullable === 'N'), + length: item.dataLength, + precision: item.dataPrecision, + scale: item.dataScale + }; + + if (pks[item.columnName]) { + schema.properties[propName].id = pks[item.columnName]; + } + schema.properties[propName][dbType] = { + columnName: i.columnName, + dataType: i.dataType, + dataLength: i.dataLength, + dataPrecision: item.dataPrecision, + dataScale: item.dataScale, + nullable: i.nullable + }; + }); + + // Add current modelName to the visited tables + options.visited = options.visited || {}; + var schemaKey = columns[0].owner + '.' + modelName; + if (!options.visited.hasOwnProperty(schemaKey)) { + if (self.settings.debug) { + debug('Adding schema for ' + schemaKey); + } + options.visited[schemaKey] = schema; + } + + var otherTables = {}; + var followingRelations = options.associations || options.relations; + if (followingRelations) { + // Handle foreign keys + var fks = {}; + var foreignKeys = this.discoverForeignKeysSync(modelName, options); + foreignKeys.forEach(function (fk) { + var fkInfo = { + keySeq: fk.keySeq, + owner: fk.pkOwner, + tableName: fk.pkTableName, + columnName: fk.pkColumnName + }; + if (fks[fk.fkName]) { + fks[fk.fkName].push(fkInfo); + } else { + fks[fk.fkName] = [fkInfo]; + } + }); + + if (self.settings.debug) { + debug('Foreign keys: ', fks); + } + + schema.options.relations = {}; + foreignKeys.forEach(function (fk) { + var propName = nameMapper('column', fk.pkTableName); + schema.options.relations[propName] = { + model: nameMapper('table', fk.pkTableName), + type: 'belongsTo', + foreignKey: nameMapper('column', fk.fkColumnName) + }; + + var key = fk.pkOwner + '.' + fk.pkTableName; + if (!options.visited.hasOwnProperty(key) && !otherTables.hasOwnProperty(key)) { + otherTables[key] = {owner: fk.pkOwner, tableName: fk.pkTableName}; + } + }); + } + + if (Object.keys(otherTables).length === 0) { + return options.visited; + } else { + var moreTasks = []; + for (var t in otherTables) { + if (self.settings.debug) { + debug('Discovering related schema for ' + schemaKey); + } + var newOptions = {}; + for (var key in options) { + newOptions[key] = options[key]; + } + newOptions.owner = otherTables[t].owner; + self.discoverSchemasSync(otherTables[t].tableName, newOptions); + } + return options.visited; + + } +}; + +/** + * Discover and build models from the specified owner/modelName. + * + * @param {String} modelName The model name. + * @options {Object} [options] Options; see below. + * @property {String} owner|schema Database owner or schema name. + * @property {Boolean} relations True if relations (primary key/foreign key) are navigated; false otherwise. + * @property {Boolean} all True if all owners are included; false otherwise. + * @property {Boolean} views True if views are included; false otherwise. + * @param {Function} [cb] The callback function + */ +DataSource.prototype.discoverAndBuildModels = function (modelName, options, cb) { + var self = this; + options = options || {}; + this.discoverSchemas(modelName, options, function (err, schemas) { + if (err) { + cb && cb(err, schemas); + return; + } + + var schemaList = []; + for (var s in schemas) { + var schema = schemas[s]; + if (options.base) { + schema.options = schema.options || {}; + schema.options.base = options.base; + } + schemaList.push(schema); + } + + var models = self.modelBuilder.buildModels(schemaList, + self.createModel.bind(self)); + + cb && cb(err, models); + }); +}; + +/** + * Discover and build models from the given owner/modelName synchronously. + * + * @param {String} modelName The model name. + * @options {Object} [options] Options; see below. + * @property {String} owner|schema Database owner or schema name. + * @property {Boolean} relations True if relations (primary key/foreign key) are navigated; false otherwise. + * @property {Boolean} all True if all owners are included; false otherwise. + * @property {Boolean} views True if views are included; false otherwise. + + * @param {String} modelName The model name + * @param {Object} [options] The options + */ +DataSource.prototype.discoverAndBuildModelsSync = function (modelName, options) { + options = options || {}; + var schemas = this.discoverSchemasSync(modelName, options); + + var schemaList = []; + for (var s in schemas) { + var schema = schemas[s]; + if (options.base) { + schema.options = schema.options || {}; + schema.options.base = options.base; + } + schemaList.push(schema); + } + + var models = this.modelBuilder.buildModels(schemaList, + this.createModel.bind(this)); + + return models; +}; + +/** + * Introspect a JSON object and build a model class + * @param {String} name Name of the model + * @param {Object} json The json object representing a model instance + * @param {Object} options Options + * @returns {*} + */ +DataSource.prototype.buildModelFromInstance = function (name, json, options) { + + // Introspect the JSON document to generate a schema + var schema = ModelBuilder.introspect(json); + + // Create a model for the generated schema + return this.createModel(name, schema, options); +}; + +/** + * Check whether migrations needed + * This method applies only to SQL connectors. + * @param {String|String[]} [models] A model name or an array of model names. If not present, apply to all models. + */ +DataSource.prototype.isActual = function (models, cb) { + this.freeze(); + if (this.connector.isActual) { + this.connector.isActual(models, cb); + } else { + if ((!cb) && ('function' === typeof models)) { + cb = models; + models = undefined; + } + if (cb) { + process.nextTick(function () { + cb(null, true); + }); + } + } +}; + +/** + * Log benchmarked message. Do not redefine this method, if you need to grab + * chema logs, use `dataSource.on('log', ...)` emitter event + * + * @private used by connectors + */ +DataSource.prototype.log = function (sql, t) { + debug(sql, t); + this.emit('log', sql, t); +}; + +/** + * Freeze dataSource. Behavior depends on connector + */ +DataSource.prototype.freeze = function freeze() { + if (!this.connector) { + throw new Error('The connector has not been initialized.'); + } + if (this.connector.freezeDataSource) { + this.connector.freezeDataSource(); + } + if (this.connector.freezeSchema) { + this.connector.freezeSchema(); + } +}; + +/** + * Return table name for specified `modelName` + * @param {String} modelName The model name + */ +DataSource.prototype.tableName = function (modelName) { + return this.getModelDefinition(modelName).tableName(this.connector.name); +}; + +/** + * Return column name for specified modelName and propertyName + * @param {String} modelName The model name + * @param {String} propertyName The property name + * @returns {String} columnName The column name. + */ +DataSource.prototype.columnName = function (modelName, propertyName) { + return this.getModelDefinition(modelName).columnName(this.connector.name, propertyName); +}; + +/** + * Return column metadata for specified modelName and propertyName + * @param {String} modelName The model name + * @param {String} propertyName The property name + * @returns {Object} column metadata + */ +DataSource.prototype.columnMetadata = function (modelName, propertyName) { + return this.getModelDefinition(modelName).columnMetadata(this.connector.name, propertyName); +}; + +/** + * Return column names for specified modelName + * @param {String} modelName The model name + * @returns {String[]} column names + */ +DataSource.prototype.columnNames = function (modelName) { + return this.getModelDefinition(modelName).columnNames(this.connector.name); +}; + +/** + * Find the ID column name + * @param {String} modelName The model name + * @returns {String} columnName for ID + */ +DataSource.prototype.idColumnName = function (modelName) { + return this.getModelDefinition(modelName).idColumnName(this.connector.name); +}; + +/** + * Find the ID property name + * @param {String} modelName The model name + * @returns {String} property name for ID + */ +DataSource.prototype.idName = function (modelName) { + if (!this.getModelDefinition(modelName).idName) { + console.error('No id name', this.getModelDefinition(modelName)); + } + return this.getModelDefinition(modelName).idName(); +}; + +/** + * Find the ID property names sorted by the index + * @param {String} modelName The model name + * @returns {String[]} property names for IDs + */ +DataSource.prototype.idNames = function (modelName) { + return this.getModelDefinition(modelName).idNames(); +}; + +/** + * Find the id property definition + * @param {String} modelName The model name + * @returns {Object} The id property definition + */ +DataSource.prototype.idProperty = function (modelName) { + var def = this.getModelDefinition(modelName); + var idProps = def && def.ids(); + return idProps && idProps[0] && idProps[0].property; +}; + +/** + * Define foreign key to another model + * @param {String} className The model name that owns the key + * @param {String} key Name of key field + * @param {String} foreignClassName The foreign model name + * @param {String} pkName (optional) primary key used for foreignKey + */ +DataSource.prototype.defineForeignKey = function defineForeignKey(className, key, foreignClassName, pkName) { + var pkType = null; + var foreignModel = this.getModelDefinition(foreignClassName); + pkName = pkName || foreignModel && foreignModel.idName(); + if (pkName) { + pkType = foreignModel.properties[pkName].type; + } + var model = this.getModelDefinition(className); + if (model.properties[key]) { + if (pkType) { + // Reset the type of the foreign key + model.rawProperties[key].type = model.properties[key].type = pkType; + } + return; + } + + var fkDef = {type: pkType}; + var foreignMeta = this.columnMetadata(foreignClassName, pkName); + if (foreignMeta && (foreignMeta.dataType || foreignMeta.dataLength)) { + fkDef[this.connector.name] = {}; + if (foreignMeta.dataType) { + fkDef[this.connector.name].dataType = foreignMeta.dataType; + } + if (foreignMeta.dataLength) { + fkDef[this.connector.name].dataLength = foreignMeta.dataLength; + } + } + if (this.connector.defineForeignKey) { + var cb = function (err, keyType) { + if (err) throw err; + fkDef.type = keyType || pkType; + // Add the foreign key property to the data source _models + this.defineProperty(className, key, fkDef); + }.bind(this); + switch (this.connector.defineForeignKey.length) { + case 4: + this.connector.defineForeignKey(className, key, foreignClassName, cb); + break; + default: + case 3: + this.connector.defineForeignKey(className, key, cb); + break; + } + } else { + // Add the foreign key property to the data source _models + this.defineProperty(className, key, fkDef); + } + +}; + +/** + * Close database connection + * @param {Function} [cb] The callback function. Optional. + */ +DataSource.prototype.disconnect = function disconnect(cb) { + var self = this; + if (this.connected && (typeof this.connector.disconnect === 'function')) { + this.connector.disconnect(function (err, result) { + self.connected = false; + cb && cb(err, result); + }); + } else { + process.nextTick(function () { + cb && cb(); + }); + } +}; + +/** + * Copy the model from Master. + * @param {Function} Master The model constructor + * @returns {Function} The copy of the model constructor + * + * @private + */ +DataSource.prototype.copyModel = function copyModel(Master) { + var dataSource = this; + var className = Master.modelName; + var md = Master.modelBuilder.getModelDefinition(className); + var Slave = function SlaveModel() { + Master.apply(this, [].slice.call(arguments)); + }; + + util.inherits(Slave, Master); + + // Delegating static properties + Slave.__proto__ = Master; + + hiddenProperty(Slave, 'dataSource', dataSource); + hiddenProperty(Slave, 'modelName', className); + hiddenProperty(Slave, 'relations', Master.relations); + + if (!(className in dataSource.modelBuilder.models)) { + + // store class in model pool + dataSource.modelBuilder.models[className] = Slave; + dataSource.modelBuilder.definitions[className] = new ModelDefinition(dataSource.modelBuilder, md.name, md.properties, md.settings); + + if ((!dataSource.isTransaction) && dataSource.connector && dataSource.connector.define) { + dataSource.connector.define({ + model: Slave, + properties: md.properties, + settings: md.settings + }); + } + + } + + return Slave; +}; + +/** + * + * @returns {EventEmitter} + * @private + */ +DataSource.prototype.transaction = function () { + var dataSource = this; + var transaction = new EventEmitter(); + + for (var p in dataSource) { + transaction[p] = dataSource[p]; + } + + transaction.isTransaction = true; + transaction.origin = dataSource; + transaction.name = dataSource.name; + transaction.settings = dataSource.settings; + transaction.connected = false; + transaction.connecting = false; + transaction.connector = dataSource.connector.transaction(); + + // create blank models pool + transaction.modelBuilder = new ModelBuilder(); + transaction.models = transaction.modelBuilder.models; + transaction.definitions = transaction.modelBuilder.definitions; + + for (var i in dataSource.modelBuilder.models) { + dataSource.copyModel.call(transaction, dataSource.modelBuilder.models[i]); + } + + transaction.exec = function (cb) { + transaction.connector.exec(cb); + }; + + return transaction; +}; + +/** + * Enable remote access to a data source operation. Each [connector](#connector) has its own set of set + * remotely enabled and disabled operations. To list the operations, call `dataSource.operations()`. + * @param {String} operation The operation name + */ + +DataSource.prototype.enableRemote = function (operation) { + var op = this.getOperation(operation); + if (op) { + op.remoteEnabled = true; + } else { + throw new Error(operation + ' is not provided by the attached connector'); + } +} + +/** + * Disable remote access to a data source operation. Each [connector](#connector) has its own set of set enabled + * and disabled operations. To list the operations, call `dataSource.operations()`. + * + *```js + * var oracle = loopback.createDataSource({ + * connector: require('loopback-connector-oracle'), + * host: '...', + * ... + * }); + * oracle.disableRemote('destroyAll'); + * ``` + * **Notes:** + * + * - Disabled operations will not be added to attached models. + * - Disabling the remoting for a method only affects client access (it will still be available from server models). + * - Data sources must enable / disable operations before attaching or creating models. + * @param {String} operation The operation name + */ + +DataSource.prototype.disableRemote = function (operation) { + var op = this.getOperation(operation); + if (op) { + op.remoteEnabled = false; + } else { + throw new Error(operation + ' is not provided by the attached connector'); + } +} + +/** + * Get an operation's metadata. + * @param {String} operation The operation name + */ + +DataSource.prototype.getOperation = function (operation) { + var ops = this.operations(); + var opKeys = Object.keys(ops); + + for (var i = 0; i < opKeys.length; i++) { + var op = ops[opKeys[i]]; + + if (op.name === operation) { + return op; + } + } +} + +/** + * Return JSON object describing all operations. + * + * Example return value: + * ```js + * { + * find: { + * remoteEnabled: true, + * accepts: [...], + * returns: [...] + * enabled: true + * }, + * save: { + * remoteEnabled: true, + * prototype: true, + * accepts: [...], + * returns: [...], + * enabled: true + * }, + * ... + * } + * ``` + */ +DataSource.prototype.operations = function () { + return this._operations; +} + +/** + * Define an operation to the data source + * @param {String} name The operation name + * @param {Object} options The options + * @param {Function} fn The function + */ +DataSource.prototype.defineOperation = function (name, options, fn) { + options.fn = fn; + options.name = name; + this._operations[name] = options; +}; + +/** + * Check if the backend is a relational DB + * @returns {Boolean} + */ +DataSource.prototype.isRelational = function () { + return this.connector && this.connector.relational; +}; + +/*! + * Check if the data source is ready. + * Returns a Boolean value. + * @param {Object} obj ? + * @param {Object} args ? + */ +DataSource.prototype.ready = function (obj, args) { + var self = this; + if (this.connected) { + // Connected + return false; + } + + var method = args.callee; + // Set up a callback after the connection is established to continue the method call + + var onConnected = null, onError = null, timeoutHandle = null; + onConnected = function() { + // Remove the error handler + self.removeListener('error', onError); + if (timeoutHandle) { + clearTimeout(timeoutHandle); + } + var params = [].slice.call(args); + try { + method.apply(obj, params); + } catch (err) { + // Catch the exception and report it via callback + var cb = params.pop(); + if (typeof cb === 'function') { + process.nextTick(function() { + cb(err); + }); + } else { + throw err; + } + } + }; + onError = function (err) { + // Remove the connected listener + self.removeListener('connected', onConnected); + if (timeoutHandle) { + clearTimeout(timeoutHandle); + } + var params = [].slice.call(args); + var cb = params.pop(); + if (typeof cb === 'function') { + process.nextTick(function() { + cb(err); + }); + } + }; + this.once('connected', onConnected); + this.once('error', onError); + + // Set up a timeout to cancel the invocation + var timeout = this.settings.connectionTimeout || 5000; + timeoutHandle = setTimeout(function () { + self.removeListener('error', onError); + self.removeListener('connected', onConnected); + var params = [].slice.call(args); + var cb = params.pop(); + if (typeof cb === 'function') { + cb(new Error('Timeout in connecting after ' + timeout + ' ms')); + } + }, timeout); + + if (!this.connecting) { + this.connect(); + } + return true; +}; + +/** + * Ping the underlying connector to test the connections + * @param {Function} [cb] Callback function + */ +DataSource.prototype.ping = function (cb) { + var self = this; + if (self.connector.ping) { + this.connector.ping(cb); + } else if (self.connector.discoverModelProperties) { + self.discoverModelProperties('dummy', {}, cb); + } else { + process.nextTick(function () { + var err = self.connected ? null : 'Not connected'; + cb(err); + }); + } +}; + +/** + * Define a hidden property + * @param {Object} obj The property owner + * @param {String} key The property name + * @param {Mixed} value The default value + */ +function hiddenProperty(obj, key, value) { + Object.defineProperty(obj, key, { + writable: false, + enumerable: false, + configurable: false, + value: value + }); +} + +/** + * Define readonly property on object + * + * @param {Object} obj The property owner + * @param {String} key The property name + * @param {Mixed} value The default value + */ +function defineReadonlyProp(obj, key, value) { + Object.defineProperty(obj, key, { + writable: false, + enumerable: true, + configurable: true, + value: value + }); +} + +// Carry over a few properties/methods from the ModelBuilder as some tests use them +DataSource.Text = ModelBuilder.Text; +DataSource.JSON = ModelBuilder.JSON; +DataSource.Any = ModelBuilder.Any; + +/*! + * @deprecated Use ModelBuilder.registerType instead + * @param type + */ +DataSource.registerType = function (type) { + ModelBuilder.registerType(type); +}; diff --git a/package.json b/package.json index e60738b..7fa8bc4 100644 --- a/package.json +++ b/package.json @@ -13,13 +13,15 @@ }, "main": "index.js", "scripts": { - "pretest": "jshint .", - "test": "mocha" + "test": "mocha", + "posttest": "jshint ." }, "license": "MIT", "dependencies": { "async": "^1.0.0", - "debug": "^2.2.0" + "bluebird": "^3.1.5", + "debug": "^2.2.0", + "qs": "^6.0.2" }, "devDependencies": { "chai": "~2.3.0", diff --git a/test/CustomTypeForeignKey.test.js b/test/CustomTypeForeignKey.test.js new file mode 100644 index 0000000..880dc93 --- /dev/null +++ b/test/CustomTypeForeignKey.test.js @@ -0,0 +1,40 @@ +var should = require('./init.js'); + +var jdb = require('../'); +var DataSource = jdb.DataSource; + +var ds, Item, Variant; +describe('Datasource-specific field types for foreign keys', function () { + before(function () { + ds = new DataSource('memory'); + Item = ds.define('Item', { + "myProp": { + "id": true, + "type": "string", + "memory": { + "dataType": "string" + } + } + }); + Variant = ds.define('Variant', {}, { + relations: { + "item": { + "type": "belongsTo", + "as": "item", + "model": "Item", + "foreignKey": "myProp" + } + } + }); + }); + + it('should create foreign key with database-specific field type', function (done) { + var VariantDefinition = ds.getModelDefinition('Variant'); + should.exist(VariantDefinition); + should.exist(VariantDefinition.properties.myProp.memory); + should.exist(VariantDefinition.properties.myProp.memory.dataType); + VariantDefinition.properties.myProp.memory.dataType.should.be.equal("string"); + done(); + }); +}) +; diff --git a/test/common_test.js b/test/common_test.js new file mode 100644 index 0000000..0b251e5 --- /dev/null +++ b/test/common_test.js @@ -0,0 +1,1138 @@ +var Schema = require('../index').Schema; +var Text = Schema.Text; + +var nbSchemaRequests = 0; + +var batch; +var schemaName; + +function it(name, cases) { + batch[schemaName][name] = cases; +} + +function skip(name) { + delete batch[schemaName][name]; +} + +module.exports = function testSchema(exportCasesHere, dataSource) { + + batch = exportCasesHere; + schemaName = dataSource.name; + if (dataSource.name.match(/^\/.*\/test\/\.\.$/)) { + schemaName = schemaName.split('/').slice(-3).shift(); + } + var start; + + batch['should connect to database'] = function (test) { + start = Date.now(); + if (dataSource.connected) return test.done(); + dataSource.on('connected', test.done); + }; + + dataSource.log = function (a) { + console.log(a); + nbSchemaRequests++; + }; + + batch[schemaName] = {}; + + testOrm(dataSource); + + batch['all tests done'] = function (test) { + test.done(); + process.nextTick(allTestsDone); + }; + + function allTestsDone() { + // dataSource.disconnect(); + console.log('Test done in %dms\n', Date.now() - start); + } + +}; + +Object.defineProperty(module.exports, 'it', { + writable: true, + enumerable: false, + configurable: true, + value: it +}); + +Object.defineProperty(module.exports, 'skip', { + writable: true, + enumerable: false, + configurable: true, + value: skip +}); + +function clearAndCreate(model, data, callback) { + var createdItems = []; + model.destroyAll(function () { + nextItem(null, null); + }); + + var itemIndex = 0; + + function nextItem(err, lastItem) { + if (lastItem !== null) { + createdItems.push(lastItem); + } + if (itemIndex >= data.length) { + callback(createdItems); + return; + } + model.create(data[itemIndex], nextItem); + itemIndex++; + } +} + +function testOrm(dataSource) { + var requestsAreCounted = dataSource.name !== 'mongodb'; + + var Post, User, Passport, Log, Dog; + + it('should define class', function (test) { + + User = dataSource.define('User', { + name: { type: String, index: true }, + email: { type: String, index: true }, + bio: Text, + approved: Boolean, + joinedAt: Date, + age: Number, + passwd: { type: String, index: true } + }); + + Dog = dataSource.define('Dog', { + name: { type: String, limit: 64, allowNull: false } + }); + + Log = dataSource.define('Log', { + ownerId: { type: Number, allowNull: true }, + name: { type: String, limit: 64, allowNull: false } + }); + + Log.belongsTo(Dog, {as: 'owner', foreignKey: 'ownerId'}); + + dataSource.extendModel('User', { + settings: { type: Schema.JSON }, + extra: Object + }); + + var newuser = new User({settings: {hey: 'you'}}); + test.ok(newuser.settings); + + Post = dataSource.define('Post', { + title: { type: String, length: 255, index: true }, + subject: { type: String }, + content: { type: Text }, + date: { type: Date, default: function () { + return new Date + }, index: true }, + published: { type: Boolean, default: false, index: true }, + likes: [], + related: [RelatedPost] + }, {table: 'posts'}); + + function RelatedPost() { + } + + RelatedPost.prototype.someMethod = function () { + return this.parent; + }; + + Post.validateAsync('title', function (err, done) { + process.nextTick(done); + }); + + User.hasMany(Post, {as: 'posts', foreignKey: 'userId'}); + // creates instance methods: + // user.posts(conds) + // user.posts.build(data) // like new Post({userId: user.id}); + // user.posts.create(data) // build and save + // user.posts.find + + // User.hasOne('latestPost', {model: Post, foreignKey: 'postId'}); + + // User.hasOne(Post, {as: 'latestPost', foreignKey: 'latestPostId'}); + // creates instance methods: + // user.latestPost() + // user.latestPost.build(data) + // user.latestPost.create(data) + + Post.belongsTo(User, {as: 'author', foreignKey: 'userId'}); + // creates instance methods: + // post.author(callback) -- getter when called with function + // post.author() -- sync getter when called without params + // post.author(user) -- setter when called with object + + Passport = dataSource.define('Passport', { + number: String + }); + + Passport.belongsTo(User, {as: 'owner', foreignKey: 'ownerId'}); + User.hasMany(Passport, {as: 'passports', foreignKey: 'ownerId'}); + + var user = new User; + + test.ok(User instanceof Function); + + // class methods + test.ok(User.find instanceof Function); + test.ok(User.create instanceof Function); + + // instance methods + test.ok(user.save instanceof Function); + + dataSource.automigrate(function (err) { + if (err) { + console.log('Error while migrating'); + console.log(err); + } else { + test.done(); + } + }); + + }); + + it('should initialize object properly', function (test) { + var hw = 'Hello word', + now = Date.now(), + post = new Post({title: hw}), + anotherPost = Post({title: 'Resig style constructor'}); + + test.equal(post.title, hw); + test.ok(!post.propertyChanged('title'), 'property changed: title'); + post.title = 'Goodbye, Lenin'; + test.equal(post.title_was, hw); + test.ok(post.propertyChanged('title')); + test.strictEqual(post.published, false); + test.ok(post.date >= now); + test.ok(post.isNewRecord()); + test.ok(anotherPost instanceof Post); + test.ok(anotherPost.title, 'Resig style constructor'); + test.done(); + }); + + it('should save object', function (test) { + var title = 'Initial title', title2 = 'Hello world', + date = new Date; + + Post.create({ + title: title, + date: date + }, function (err, obj) { + test.ok(obj.id, 'Object id should present'); + test.equals(obj.title, title); + // test.equals(obj.date, date); + obj.title = title2; + test.ok(obj.propertyChanged('title'), 'Title changed'); + obj.save(function (err, obj) { + test.equal(obj.title, title2); + test.ok(!obj.propertyChanged('title')); + + var p = new Post({title: 1}); + p.title = 2; + p.save(function (err, obj) { + test.ok(!p.propertyChanged('title')); + p.title = 3; + test.ok(p.propertyChanged('title')); + test.equal(p.title_was, 2); + p.save(function () { + test.equal(p.title_was, 3); + test.ok(!p.propertyChanged('title')); + test.done(); + }); + }); + }); + }); + }); + + it('should create object with initial data', function (test) { + var title = 'Initial title', + date = new Date; + + Post.create({ + title: title, + date: date + }, function (err, obj) { + test.ok(obj.id); + test.equals(obj.title, title); + test.equals(obj.date, date); + Post.findById(obj.id, function () { + test.equal(obj.title, title); + test.equal(obj.date.toString(), date.toString()); + test.done(); + }); + }); + }); + + it('should save only dataSource-defined field in database', function (test) { + Post.create({title: '1602', nonSchemaField: 'some value'}, function (err, post) { + test.ok(!post.nonSchemaField); + post.a = 1; + post.save(function () { + test.ok(post.a); + post.reload(function (err, psto) { + test.ok(!psto.a); + test.done(); + }); + }); + }); + }); + + /* + it('should not create new instances for the same object', function (test) { + var title = 'Initial title'; + Post.create({ title: title }, function (err, post) { + test.ok(post.id, 'Object should have id'); + test.equals(post.title, title); + Post.findById(post.id, function (err, foundPost) { + if (err) throw err; + test.equal(post.title, title); + test.strictEqual(post, foundPost); + test.done(); + }); + }); + }); + */ + + it('should not re-instantiate object on saving', function (test) { + var title = 'Initial title'; + var post = new Post({title: title}); + post.save(function (err, savedPost) { + test.strictEqual(post, savedPost); + test.done(); + }); + }); + + it('should destroy object', function (test) { + Post.create(function (err, post) { + Post.exists(post.id, function (err, exists) { + test.ok(exists, 'Object exists'); + post.destroy(function () { + Post.exists(post.id, function (err, exists) { + if (err) console.log(err); + test.ok(!exists, 'Hey! ORM told me that object exists, but it looks like it doesn\'t. Something went wrong...'); + Post.findById(post.id, function (err, obj) { + test.equal(obj, null, 'Param obj should be null'); + test.done(); + }); + }); + }); + }); + }); + }); + + it('should handle virtual attributes', function (test) { + var salt = 's0m3s3cr3t5a1t'; + + User.setter.passwd = function (password) { + this._passwd = calcHash(password, salt); + }; + + function calcHash(pass, salt) { + var crypto = require('crypto'); + var hash = crypto.createHash('sha256'); + hash.update(pass); + hash.update(salt); + return hash.digest('base64'); + } + + var u = new User; + u.passwd = 's3cr3t'; + test.equal(u.passwd, calcHash('s3cr3t', salt)); + test.done(); + }); + + // it('should serialize JSON type', function (test) { + // User.create({settings: {hello: 'world'}}, function (err, user) { + // test.ok(user.id); + // test.equal(user.settings.hello, 'world'); + // User.find(user.id, function (err, u) { + // console.log(u.settings); + // test.equal(u.settings.hello, 'world'); + // test.done(); + // }); + // }); + // }); + + it('should update single attribute', function (test) { + Post.create({title: 'title', content: 'content', published: true}, function (err, post) { + post.content = 'New content'; + post.updateAttribute('title', 'New title', function () { + test.equal(post.title, 'New title'); + test.ok(!post.propertyChanged('title')); + test.equal(post.content, 'New content', 'dirty state saved'); + test.ok(post.propertyChanged('content')); + post.reload(function (err, post) { + test.equal(post.title, 'New title'); + test.ok(!post.propertyChanged('title'), 'title not changed'); + test.equal(post.content, 'content', 'real value turned back'); + test.ok(!post.propertyChanged('content'), 'content unchanged'); + test.done(); + }); + }); + }); + }); + + var countOfposts, countOfpostsFiltered; + it('should fetch collection', function (test) { + Post.all(function (err, posts) { + countOfposts = posts.length; + test.ok(countOfposts > 0); + test.ok(posts[0] instanceof Post); + countOfpostsFiltered = posts.filter(function (p) { + return p.title === 'title'; + }).length; + test.done(); + }); + }); + + it('should find records filtered with multiple attributes', function (test) { + var d = new Date; + Post.create({title: 'title', content: 'content', published: true, date: d}, function (err, post) { + Post.all({where: {title: 'title', date: d, published: true}}, function (err, res) { + test.equals(res.length, 1, 'Filtering Posts returns one post'); + test.done(); + }); + }); + }); + + if ( + !dataSource.name.match(/redis/) && + dataSource.name !== 'memory' && + dataSource.name !== 'neo4j' && + dataSource.name !== 'cradle' + ) + it('relations key is working', function (test) { + test.ok(User.relations, 'Relations key should be defined'); + test.ok(User.relations.posts, 'posts relation should exist on User'); + test.equal(User.relations.posts.type, 'hasMany', 'Type of hasMany relation is hasMany'); + test.equal(User.relations.posts.multiple, true, 'hasMany relations are multiple'); + test.equal(User.relations.posts.keyFrom, 'id', 'keyFrom is primary key of model table'); + test.equal(User.relations.posts.keyTo, 'userId', 'keyTo is foreign key of related model table'); + + test.ok(Post.relations, 'Relations key should be defined'); + test.ok(Post.relations.author, 'author relation should exist on Post'); + test.equal(Post.relations.author.type, 'belongsTo', 'Type of belongsTo relation is belongsTo'); + test.equal(Post.relations.author.multiple, false, 'belongsTo relations are not multiple'); + test.equal(Post.relations.author.keyFrom, 'userId', 'keyFrom is foreign key of model table'); + test.equal(Post.relations.author.keyTo, 'id', 'keyTo is primary key of related model table'); + test.done(); + }); + + it('should handle hasMany relationship', function (test) { + User.create(function (err, u) { + if (err) return console.log(err); + test.ok(u.posts, 'Method defined: posts'); + test.ok(u.posts.build, 'Method defined: posts.build'); + test.ok(u.posts.create, 'Method defined: posts.create'); + u.posts.create(function (err, post) { + if (err) return console.log(err); + u.posts(function (err, posts) { + test.equal(posts.pop().id.toString(), post.id.toString()); + test.done(); + }); + }); + }); + }); + + it('should navigate variations of belongsTo regardless of column name', function (test) { + + Dog.create({name: 'theDog'}, function (err, obj) { + test.ok(obj instanceof Dog); + Log.create({name: 'theLog', ownerId: obj.id}, function (err, obj) { + test.ok(obj instanceof Log); + obj.owner(function (err, obj) { + test.ok(!err, 'Should not have an error.'); // Before cba174b this would be 'Error: Permission denied' + if (err) { + console.log('Found: ' + err); + } + test.ok(obj, 'Should not find null or undefined.'); // Before cba174b this could be null or undefined. + test.ok(obj instanceof Dog, 'Should find a Dog.'); + if (obj) { // Since test won't stop on fail, have to check before accessing obj.name. + test.ok(obj.name, 'Should have a name.'); + } + if (obj && obj.name) { + test.equal(obj.name, 'theDog', 'The owner of theLog is theDog.'); + } + test.done(); + }); + }); + }); + }); + + it('hasMany should support additional conditions', function (test) { + + User.create(function (e, u) { + u.posts.create({}, function (e, p) { + u.posts({where: {id: p.id}}, function (e, posts) { + test.equal(posts.length, 1, 'There should be only 1 post.'); + test.done(); + }); + }); + }); + + }); + + it('hasMany should be cached', function (test) { + //User.create(function (e, u) { + // u.posts.create({}, function (e, p) { + // find all posts for a user. + // Finding one post with an existing author associated + Post.all(function (err, posts) { + // We try to get the first post with a userId != NULL + for (var i = 0; i < posts.length; i++) { + var post = posts[i]; + if (post.userId) { + // We could get the user with belongs to relationship but it is better if there is no interactions. + User.findById(post.userId, function (err, user) { + User.create(function (err, voidUser) { + Post.create({userId: user.id}, function () { + + // There can't be any concurrency because we are counting requests + // We are first testing cases when user has posts + user.posts(function (err, data) { + var nbInitialRequests = nbSchemaRequests; + user.posts(function (err, data2) { + test.equal(data.length, 2, 'There should be 2 posts.'); + test.equal(data.length, data2.length, 'Posts should be the same, since we are loading on the same object.'); + requestsAreCounted && test.equal(nbInitialRequests, nbSchemaRequests, 'There should not be any request because value is cached.'); + + if (dataSource.name === 'mongodb') { // for the moment mongodb doesn\'t support additional conditions on hasMany relations (see above) + test.done(); + } else { + user.posts({where: {id: data[0].id}}, function (err, data) { + test.equal(data.length, 1, 'There should be only one post.'); + requestsAreCounted && test.equal(nbInitialRequests + 1, nbSchemaRequests, 'There should be one additional request since we added conditions.'); + + user.posts(function (err, data) { + test.equal(data.length, 2, 'Previous get shouldn\'t have changed cached value though, since there was additional conditions.'); + requestsAreCounted && test.equal(nbInitialRequests + 1, nbSchemaRequests, 'There should not be any request because value is cached.'); + + // We are now testing cases when user doesn't have any post + voidUser.posts(function (err, data) { + var nbInitialRequests = nbSchemaRequests; + voidUser.posts(function (err, data2) { + test.equal(data.length, 0, 'There shouldn\'t be any posts (1/2).'); + test.equal(data2.length, 0, 'There shouldn\'t be any posts (2/2).'); + requestsAreCounted && test.equal(nbInitialRequests, nbSchemaRequests, 'There should not be any request because value is cached.'); + + voidUser.posts(true, function (err, data3) { + test.equal(data3.length, 0, 'There shouldn\'t be any posts.'); + requestsAreCounted && test.equal(nbInitialRequests + 1, nbSchemaRequests, 'There should be one additional request since we forced refresh.'); + + test.done(); + }); + }); + }); + + }); + }); + } + + }); + }); + + }); + }); + }); + break; + } + } + }); + + }); + + // it('should handle hasOne relationship', function (test) { + // User.create(function (err, u) { + // if (err) return console.log(err); + // }); + // }); + + it('should support scopes', function (test) { + var wait = 2; + + test.ok(Post.scope, 'Scope supported'); + Post.scope('published', {where: {published: true}}); + test.ok(typeof Post.published === 'function'); + test.ok(Post.published._scope.where.published === true); + var post = Post.published.build(); + test.ok(post.published, 'Can build'); + test.ok(post.isNewRecord()); + Post.published.create(function (err, psto) { + if (err) return console.log(err); + test.ok(psto.published); + test.ok(!psto.isNewRecord()); + done(); + }); + + User.create(function (err, u) { + if (err) return console.log(err); + test.ok(typeof u.posts.published == 'function'); + test.ok(u.posts.published._scope.where.published); + console.log(u.posts.published._scope); + test.equal(u.posts.published._scope.where.userId, u.id); + done(); + }); + + function done() { + if (--wait === 0) test.done(); + }; + }); + + it('should return type of property', function (test) { + test.equal(Post.getPropertyType('title'), 'String'); + test.equal(Post.getPropertyType('content'), 'Text'); + var p = new Post; + test.equal(p.getPropertyType('title'), 'String'); + test.equal(p.getPropertyType('content'), 'Text'); + test.done(); + }); + + it('should handle ORDER clause', function (test) { + var titles = [ + { title: 'Title A', subject: "B" }, + { title: 'Title Z', subject: "A" }, + { title: 'Title M', subject: "C" }, + { title: 'Title A', subject: "A" }, + { title: 'Title B', subject: "A" }, + { title: 'Title C', subject: "D" } + ]; + var isRedis = Post.dataSource.name === 'redis'; + var dates = isRedis ? [ 5, 9, 0, 17, 10, 9 ] : [ + new Date(1000 * 5), + new Date(1000 * 9), + new Date(1000 * 0), + new Date(1000 * 17), + new Date(1000 * 10), + new Date(1000 * 9) + ]; + titles.forEach(function (t, i) { + Post.create({title: t.title, subject: t.subject, date: dates[i]}, done); + }); + + var i = 0, tests = 0; + + function done(err, obj) { + if (++i === titles.length) { + doFilterAndSortTest(); + doFilterAndSortReverseTest(); + doStringTest(); + doNumberTest(); + + if (dataSource.name == 'mongoose') { + doMultipleSortTest(); + doMultipleReverseSortTest(); + } + } + } + + function compare(a, b) { + if (a.title < b.title) return -1; + if (a.title > b.title) return 1; + return 0; + } + + // Post.dataSource.log = console.log; + + function doStringTest() { + tests += 1; + Post.all({order: 'title'}, function (err, posts) { + if (err) console.log(err); + test.equal(posts.length, 6); + titles.sort(compare).forEach(function (t, i) { + if (posts[i]) test.equal(posts[i].title, t.title); + }); + finished(); + }); + } + + function doNumberTest() { + tests += 1; + Post.all({order: 'date'}, function (err, posts) { + if (err) console.log(err); + test.equal(posts.length, 6); + dates.sort(numerically).forEach(function (d, i) { + if (posts[i]) + test.equal(posts[i].date.toString(), d.toString(), 'doNumberTest'); + }); + finished(); + }); + } + + function doFilterAndSortTest() { + tests += 1; + Post.all({where: {date: new Date(1000 * 9)}, order: 'title', limit: 3}, function (err, posts) { + if (err) console.log(err); + console.log(posts.length); + test.equal(posts.length, 2, 'Exactly 2 posts returned by query'); + [ 'Title C', 'Title Z' ].forEach(function (t, i) { + if (posts[i]) { + test.equal(posts[i].title, t, 'doFilterAndSortTest'); + } + }); + finished(); + }); + } + + function doFilterAndSortReverseTest() { + tests += 1; + Post.all({where: {date: new Date(1000 * 9)}, order: 'title DESC', limit: 3}, function (err, posts) { + if (err) console.log(err); + test.equal(posts.length, 2, 'Exactly 2 posts returned by query'); + [ 'Title Z', 'Title C' ].forEach(function (t, i) { + if (posts[i]) { + test.equal(posts[i].title, t, 'doFilterAndSortReverseTest'); + } + }); + finished(); + }); + } + + function doMultipleSortTest() { + tests += 1; + Post.all({order: "title ASC, subject ASC"}, function (err, posts) { + if (err) console.log(err); + test.equal(posts.length, 6); + test.equal(posts[0].title, "Title A"); + test.equal(posts[0].subject, "A"); + test.equal(posts[1].title, "Title A"); + test.equal(posts[1].subject, "B"); + test.equal(posts[5].title, "Title Z"); + finished(); + }); + } + + function doMultipleReverseSortTest() { + tests += 1; + Post.all({order: "title ASC, subject DESC"}, function (err, posts) { + if (err) console.log(err); + test.equal(posts.length, 6); + test.equal(posts[0].title, "Title A"); + test.equal(posts[0].subject, "B"); + test.equal(posts[1].title, "Title A"); + test.equal(posts[1].subject, "A"); + test.equal(posts[5].title, "Title Z"); + finished(); + }); + } + + var fin = 0; + + function finished() { + if (++fin === tests) { + test.done(); + } + } + + // TODO: do mixed test, do real dates tests, ensure that dates stored in UNIX timestamp format + + function numerically(a, b) { + return a - b; + } + + }); + + // if ( + // !dataSource.name.match(/redis/) && + // dataSource.name !== 'memory' && + // dataSource.name !== 'neo4j' && + // dataSource.name !== 'cradle' && + // dataSource.name !== 'nano' + // ) + // it('should allow advanced queying: lt, gt, lte, gte, between', function (test) { + // Post.destroyAll(function () { + // Post.create({date: new Date('Wed, 01 Feb 2012 13:56:12 GMT')}, done); + // Post.create({date: new Date('Thu, 02 Feb 2012 13:56:12 GMT')}, done); + // Post.create({date: new Date('Fri, 03 Feb 2012 13:56:12 GMT')}, done); + // Post.create({date: new Date('Sat, 04 Feb 2012 13:56:12 GMT')}, done); + // Post.create({date: new Date('Sun, 05 Feb 2012 13:56:12 GMT')}, done); + // Post.create({date: new Date('Mon, 06 Feb 2012 13:56:12 GMT')}, done); + // Post.create({date: new Date('Tue, 07 Feb 2012 13:56:12 GMT')}, done); + // Post.create({date: new Date('Wed, 08 Feb 2012 13:56:12 GMT')}, done); + // Post.create({date: new Date('Thu, 09 Feb 2012 13:56:12 GMT')}, done); + // }); + + // var posts = 9; + // function done() { + // if (--posts === 0) makeTest(); + // } + + // function makeTest() { + // // gt + // Post.all({where: {date: {gt: new Date('Tue, 07 Feb 2012 13:56:12 GMT')}}}, function (err, posts) { + // test.equal(posts.length, 2, 'gt'); + // ok(); + // }); + + // // gte + // Post.all({where: {date: {gte: new Date('Tue, 07 Feb 2012 13:56:12 GMT')}}}, function (err, posts) { + // test.equal(posts.length, 3, 'gte'); + // ok(); + // }); + + // // lte + // Post.all({where: {date: {lte: new Date('Tue, 07 Feb 2012 13:56:12 GMT')}}}, function (err, posts) { + // test.equal(posts.length, 7, 'lte'); + // ok(); + // }); + + // // lt + // Post.all({where: {date: {lt: new Date('Tue, 07 Feb 2012 13:56:12 GMT')}}}, function (err, posts) { + // test.equal(posts.length, 6, 'lt'); + // ok(); + // }); + + // // between + // Post.all({where: {date: {between: [new Date('Tue, 05 Feb 2012 13:56:12 GMT'), new Date('Tue, 09 Feb 2012 13:56:12 GMT')]}}}, function (err, posts) { + // test.equal(posts.length, 5, 'between'); + // ok(); + // }); + // } + + // var tests = 5; + // function ok() { + // if (--tests === 0) test.done(); + // } + // }); + + // if ( + // dataSource.name === 'mysql' || + // dataSource.name === 'postgres' + // ) + // it('should allow IN or NOT IN', function (test) { + // User.destroyAll(function () { + // User.create({name: 'User A', age: 21}, done); + // User.create({name: 'User B', age: 22}, done); + // User.create({name: 'User C', age: 23}, done); + // User.create({name: 'User D', age: 24}, done); + // User.create({name: 'User E', age: 25}, done); + // }); + + // var users = 5; + // function done() { + // if (--users === 0) makeTest(); + // } + + // function makeTest() { + // // IN with empty array should return nothing + // User.all({where: {name: {inq: []}}}, function (err, users) { + // test.equal(users.length, 0, 'IN with empty array returns nothing'); + // ok(); + // }); + + // // NOT IN with empty array should return everything + // User.all({where: {name: {nin: []}}}, function (err, users) { + // test.equal(users.length, 5, 'NOT IN with empty array returns everything'); + // ok(); + // }); + + // // IN [User A] returns user with name = User A + // User.all({where: {name: {inq: ['User A']}}}, function (err, users) { + // test.equal(users.length, 1, 'IN searching one existing value returns 1 user'); + // test.equal(users[0].name, 'User A', 'IN [User A] returns user with name = User A'); + // ok(); + // }); + + // // NOT IN [User A] returns users with name != User A + // User.all({where: {name: {nin: ['User A']}}}, function (err, users) { + // test.equal(users.length, 4, 'IN [User A] returns users with name != User A'); + // ok(); + // }); + + // // IN [User A, User B] returns users with name = User A OR name = User B + // User.all({where: {name: {inq: ['User A', 'User B']}}}, function (err, users) { + // test.equal(users.length, 2, 'IN searching two existing values returns 2 users'); + // ok(); + // }); + + // // NOT IN [User A, User B] returns users with name != User A AND name != User B + // User.all({where: {name: {nin: ['User A', 'User B']}}}, function (err, users) { + // test.equal(users.length, 3, 'NOT IN searching two existing values returns users with name != User A AND name != User B'); + // ok(); + // }); + + // // IN works with numbers too + // User.all({where: {age: {inq: [21, 22]}}}, function (err, users) { + // test.equal(users.length, 2, 'IN works with numbers too'); + // ok(); + // }); + + // // NOT IN works with numbers too + // User.all({where: {age: {nin: [21, 22]}}}, function (err, users) { + // test.equal(users.length, 3, 'NOT IN works with numbers too'); + // ok(); + // }); + // } + + // var tests = 8; + // function ok() { + // if (--tests === 0) test.done(); + // } + // }); + + it('should handle order clause with direction', function (test) { + var wait = 0; + var emails = [ + 'john@hcompany.com', + 'tom@hcompany.com', + 'admin@hcompany.com', + 'tin@hcompany.com', + 'mike@hcompany.com', + 'susan@hcompany.com', + 'test@hcompany.com' + ]; + User.destroyAll(function () { + emails.forEach(function (email) { + wait += 1; + User.create({email: email, name: 'Nick'}, done); + }); + }); + var tests = 2; + + function done() { + process.nextTick(function () { + if (--wait === 0) { + doSortTest(); + doReverseSortTest(); + } + }); + } + + function doSortTest() { + User.all({order: 'email ASC', where: {name: 'Nick'}}, function (err, users) { + var _emails = emails.sort(); + users.forEach(function (user, i) { + test.equal(_emails[i], user.email, 'ASC sorting'); + }); + testDone(); + }); + } + + function doReverseSortTest() { + User.all({order: 'email DESC', where: {name: 'Nick'}}, function (err, users) { + var _emails = emails.sort().reverse(); + users.forEach(function (user, i) { + test.equal(_emails[i], user.email, 'DESC sorting'); + }); + testDone(); + }); + } + + function testDone() { + if (--tests === 0) test.done(); + } + }); + + it('should return id in find result even after updateAttributes', function (test) { + Post.create(function (err, post) { + var id = post.id; + test.ok(post.published === false); + post.updateAttributes({title: 'hey', published: true}, function () { + Post.find(id, function (err, post) { + test.ok(!!post.published, 'Update boolean field'); + test.ok(post.id); + test.done(); + }); + }); + }); + }); + + it('should handle belongsTo correctly', function (test) { + var passport = new Passport({ownerId: 16}); + // sync getter + test.equal(passport.owner(), 16); + // sync setter + passport.owner(18); + test.equal(passport.owner(), 18); + test.done(); + }); + + it('should query one record', function (test) { + test.expect(4); + Post.findOne(function (err, post) { + test.ok(post && post.id); + Post.findOne({ where: { title: 'hey' } }, function (err, post) { + if (err) { + console.log(err); + return test.done(); + } + test.equal(post && post.constructor.modelName, 'Post'); + test.equal(post && post.title, 'hey'); + Post.findOne({ where: { title: 'not exists' } }, function (err, post) { + test.ok(post === null); + test.done(); + }); + }); + }); + }); + + // if ( + // !dataSource.name.match(/redis/) && + // dataSource.name !== 'memory' && + // dataSource.name !== 'neo4j' && + // dataSource.name !== 'cradle' && + // dataSource.name !== 'nano' + // ) + // it('belongsTo should be cached', function (test) { + // User.findOne(function(err, user) { + + // var passport = new Passport({ownerId: user.id}); + // var passport2 = new Passport({ownerId: null}); + + // // There can't be any concurrency because we are counting requests + // // We are first testing cases when passport has an owner + // passport.owner(function(err, data) { + // var nbInitialRequests = nbSchemaRequests; + // passport.owner(function(err, data2) { + // test.equal(data.id, data2.id, 'The value should remain the same'); + // requestsAreCounted && test.equal(nbInitialRequests, nbSchemaRequests, 'There should not be any request because value is cached.'); + + // // We are now testing cases when passport has not an owner + // passport2.owner(function(err, data) { + // var nbInitialRequests2 = nbSchemaRequests; + // passport2.owner(function(err, data2) { + // test.equal(data, null, 'The value should be null since there is no owner'); + // test.equal(data, data2, 'The value should remain the same (null)'); + // requestsAreCounted && test.equal(nbInitialRequests2, nbSchemaRequests, 'There should not be any request because value is cached.'); + + // passport2.owner(user.id); + // passport2.owner(function(err, data3) { + // test.equal(data3.id, user.id, 'Owner should now be the user.'); + // requestsAreCounted && test.equal(nbInitialRequests2 + 1, nbSchemaRequests, 'If we changed owner id, there should be one more request.'); + + // passport2.owner(true, function(err, data4) { + // test.equal(data3.id, data3.id, 'The value should remain the same'); + // requestsAreCounted && test.equal(nbInitialRequests2 + 2, nbSchemaRequests, 'If we forced refreshing, there should be one more request.'); + // test.done(); + // }); + // }); + // }); + // }); + + // }); + // }); + // }); + + // }); + + if (dataSource.name !== 'mongoose' && dataSource.name !== 'neo4j') + it('should update or create record', function (test) { + var newData = { + id: 1, + title: 'New title (really new)', + content: 'Some example content (updated)' + }; + Post.updateOrCreate(newData, function (err, updatedPost) { + if (err) throw err; + test.ok(updatedPost); + if (!updatedPost) throw Error('No post!'); + + if (dataSource.name !== 'mongodb') { + test.equal(newData.id, updatedPost.toObject().id); + } + test.equal(newData.title, updatedPost.toObject().title); + test.equal(newData.content, updatedPost.toObject().content); + + Post.findById(updatedPost.id, function (err, post) { + if (err) throw err; + if (!post) throw Error('No post!'); + if (dataSource.name !== 'mongodb') { + test.equal(newData.id, post.toObject().id); + } + test.equal(newData.title, post.toObject().title); + test.equal(newData.content, post.toObject().content); + Post.updateOrCreate({id: 100001, title: 'hey'}, function (err, post) { + if (dataSource.name !== 'mongodb') test.equal(post.id, 100001); + test.equal(post.title, 'hey'); + Post.findById(post.id, function (err, post) { + if (!post) throw Error('No post!'); + test.done(); + }); + }); + }); + }); + }); + + it('should work with custom setters and getters', function (test) { + User.dataSource.defineForeignKey('User', 'passwd'); + User.setter.passwd = function (pass) { + this._passwd = pass + 'salt'; + }; + var u = new User({passwd: 'qwerty'}); + test.equal(u.passwd, 'qwertysalt'); + u.save(function (err, user) { + User.findById(user.id, function (err, user) { + test.ok(user !== u); + test.equal(user.passwd, 'qwertysalt'); + User.all({where: {passwd: 'qwertysalt'}}, function (err, users) { + test.ok(users[0] !== user); + test.equal(users[0].passwd, 'qwertysalt'); + User.create({passwd: 'asalat'}, function (err, usr) { + test.equal(usr.passwd, 'asalatsalt'); + User.upsert({passwd: 'heyman'}, function (err, us) { + test.equal(us.passwd, 'heymansalt'); + User.findById(us.id, function (err, user) { + test.equal(user.passwd, 'heymansalt'); + test.done(); + }); + }); + }); + }); + }); + }); + }); + + it('should work with typed and untyped nested collections', function (test) { + var post = new Post; + var like = post.likes.push({foo: 'bar'}); + test.equal(like.constructor.name, 'ListItem'); + var related = post.related.push({hello: 'world'}); + test.ok(related.someMethod); + post.save(function (err, p) { + test.equal(p.likes.nextid, 2); + p.likes.push({second: 2}); + p.likes.push({third: 3}); + p.save(function (err) { + Post.findById(p.id, function (err, pp) { + test.equal(pp.likes.length, 3); + test.ok(pp.likes[3].third); + test.ok(pp.likes[2].second); + test.ok(pp.likes[1].foo); + pp.likes.remove(2); + test.equal(pp.likes.length, 2); + test.ok(!pp.likes[2]); + pp.likes.remove(pp.likes[1]); + test.equal(pp.likes.length, 1); + test.ok(!pp.likes[1]); + test.ok(pp.likes[3]); + pp.save(function () { + Post.findById(p.id, function (err, pp) { + test.equal(pp.likes.length, 1); + test.ok(!pp.likes[1]); + test.ok(pp.likes[3]); + test.done(); + }); + }); + }); + }); + }); + }); + + it('should find or create', function (test) { + var email = 'some email ' + Math.random(); + User.findOrCreate({where: {email: email}}, function (err, u, created) { + test.ok(u); + test.ok(!u.age); + test.ok(created); + User.findOrCreate({where: {email: email}}, {age: 21}, function (err, u2, created) { + test.equals(u.id.toString(), u2.id.toString(), 'Same user ids'); + test.ok(!u2.age); + test.ok(!created); + test.done(); + }); + }); + }); + +} diff --git a/test/defaults.test.js b/test/defaults.test.js new file mode 100644 index 0000000..6a05e0c --- /dev/null +++ b/test/defaults.test.js @@ -0,0 +1,72 @@ +// This test written in mocha+should.js +var should = require('./init.js'); + +var db = getSchema(); + +describe('defaults', function () { + var Server; + + before(function () { + Server = db.define('Server', { + host: String, + port: {type: Number, default: 80}, + createdAt: {type: Date, default: '$now'} + }); + }); + + it('should apply defaults on new', function () { + var s = new Server; + s.port.should.equal(80); + }); + + it('should apply defaults on create', function (done) { + Server.create(function (err, s) { + s.port.should.equal(80); + done(); + }); + }); + + it('should apply defaults on read', function (done) { + db.defineProperty('Server', 'host', { + type: String, + default: 'localhost' + }); + Server.all(function (err, servers) { + (new String('localhost')).should.equal(servers[0].host); + done(); + }); + }); + + it('should ignore defaults with limited fields', function (done) { + Server.create({ host: 'localhost', port: 8080 }, function(err, s) { + should.not.exist(err); + s.port.should.equal(8080); + Server.find({ fields: ['host'] }, function (err, servers) { + servers[0].host.should.equal('localhost'); + servers[0].should.have.property('host'); + servers[0].should.have.property('port', undefined); + done(); + }); + }); + }); + + it('should apply defaults in upsert create', function (done) { + Server.upsert({port: 8181 }, function(err, server) { + should.not.exist(err); + should.exist(server.createdAt); + done(); + }); + }); + + it('should preserve defaults in upsert update', function (done) { + Server.findOne({}, function(err, server) { + Server.upsert({id:server.id, port: 1337 }, function(err, s) { + should.not.exist(err); + (Number(1337)).should.equal(s.port); + server.createdAt.should.eql(s.createdAt); + done(); + }); + }); + }); + +}); diff --git a/test/discovery.test.js b/test/discovery.test.js new file mode 100644 index 0000000..26e5056 --- /dev/null +++ b/test/discovery.test.js @@ -0,0 +1,604 @@ +var jdb = require('../'); +var DataSource = jdb.DataSource; +var should = require('./init.js'); + +describe('Memory connector with mocked discovery', function() { + var ds; + + before(function() { + ds = new DataSource({connector: 'memory'}); + + var models = [{type: 'table', name: 'CUSTOMER', owner: 'STRONGLOOP'}, + {type: 'table', name: 'INVENTORY', owner: 'STRONGLOOP'}, + {type: 'table', name: 'LOCATION', owner: 'STRONGLOOP'}]; + + ds.discoverModelDefinitions = function(options, cb) { + process.nextTick(function() { + cb(null, models); + }); + }; + + var modelProperties = [{ + owner: 'STRONGLOOP', + tableName: 'INVENTORY', + columnName: 'PRODUCT_ID', + dataType: 'varchar', + dataLength: 20, + dataPrecision: null, + dataScale: null, + nullable: 0 + }, + { + owner: 'STRONGLOOP', + tableName: 'INVENTORY', + columnName: 'LOCATION_ID', + dataType: 'varchar', + dataLength: 20, + dataPrecision: null, + dataScale: null, + nullable: 0 + }, + { + owner: 'STRONGLOOP', + tableName: 'INVENTORY', + columnName: 'AVAILABLE', + dataType: 'int', + dataLength: null, + dataPrecision: 10, + dataScale: 0, + nullable: 1 + }, + { + owner: 'STRONGLOOP', + tableName: 'INVENTORY', + columnName: 'TOTAL', + dataType: 'int', + dataLength: null, + dataPrecision: 10, + dataScale: 0, + nullable: 1 + }]; + + ds.discoverModelProperties = function(modelName, options, cb) { + process.nextTick(function() { + cb(null, modelProperties); + }); + }; + }); + + it('should convert table/column names to camel cases', function(done) { + ds.discoverSchemas('INVENTORY', {}, function(err, schemas) { + if (err) return done(err); + schemas.should.have.property('STRONGLOOP.INVENTORY'); + var s = schemas['STRONGLOOP.INVENTORY']; + s.name.should.be.eql('Inventory'); + Object.keys(s.properties).should.be.eql( + ['productId', 'locationId', 'available', 'total']); + done(); + }); + }); + + it('should convert table/column names with custom mapper', function(done) { + ds.discoverSchemas('INVENTORY', { + nameMapper: function(type, name) { + // Convert all names to lower case + return name.toLowerCase(); + } + }, function(err, schemas) { + if (err) return done(err); + schemas.should.have.property('STRONGLOOP.INVENTORY'); + var s = schemas['STRONGLOOP.INVENTORY']; + s.name.should.be.eql('inventory'); + Object.keys(s.properties).should.be.eql( + ['product_id', 'location_id', 'available', 'total']); + done(); + }); + }); + + it('should not convert table/column names with null custom mapper', + function(done) { + ds.discoverSchemas('INVENTORY', {nameMapper: null}, function(err, schemas) { + if (err) return done(err); + schemas.should.have.property('STRONGLOOP.INVENTORY'); + var s = schemas['STRONGLOOP.INVENTORY']; + s.name.should.be.eql('INVENTORY'); + Object.keys(s.properties).should.be.eql( + ['PRODUCT_ID', 'LOCATION_ID', 'AVAILABLE', 'TOTAL']); + done(); + }); + }); + + it('should honor connector\'s discoverSchemas implementation', + function(done) { + var models = { + inventory: { + product: {type: 'string'}, + location: {type: 'string'} + } + }; + ds.connector.discoverSchemas = function(modelName, options, cb) { + process.nextTick(function() { + cb(null, models); + }); + }; + ds.discoverSchemas('INVENTORY', {nameMapper: null}, function(err, schemas) { + if (err) return done(err); + schemas.should.be.eql(models); + done(); + }); + }); + + it('should callback function, passed as options parameter', + function(done) { + var models = { + inventory: { + product: {type: 'string'}, + location: {type: 'string'} + } + }; + ds.connector.discoverSchemas = function(modelName, options, cb) { + process.nextTick(function() { + cb(null, models); + }); + }; + + var options = function(err, schemas) { + if (err) return done(err); + schemas.should.be.eql(models); + done(); + }; + + ds.discoverSchemas('INVENTORY', options); + }); + + it('should discover schemas using `discoverSchemas` - promise variant', + function(done) { + ds.connector.discoverSchemas = null; + ds.discoverSchemas('INVENTORY', {}) + .then(function(schemas) { + schemas.should.have.property('STRONGLOOP.INVENTORY'); + + var s = schemas['STRONGLOOP.INVENTORY']; + s.name.should.be.eql('Inventory'); + + Object.keys(s.properties).should.be.eql( + ['productId', 'locationId', 'available', 'total'] + ); + done(); + }) + .catch(function(err) { + done(err); + }); + }); + + describe('discoverSchema', function(){ + var models; + var schema; + before(function() { + schema = { + name: 'Inventory', + options: { + idInjection: false, + memory: { schema: 'STRONGLOOP', table: 'INVENTORY' } + }, + properties: { + available: { + length: null, + memory: { + columnName: 'AVAILABLE', + dataLength: null, + dataPrecision: 10, + dataScale: 0, + dataType: 'int', + nullable: 1 + }, + precision: 10, + required: false, + scale: 0, + type: undefined + }, + locationId: { + length: 20, + memory: { + columnName: 'LOCATION_ID', + dataLength: 20, + dataPrecision: null, + dataScale: null, + dataType: 'varchar', + nullable: 0 + }, + precision: null, + required: true, + scale: null, + type: undefined + }, + productId: { + length: 20, + memory: { + columnName: 'PRODUCT_ID', + dataLength: 20, + dataPrecision: null, + dataScale: null, + dataType: 'varchar', + nullable: 0 + }, + precision: null, + required: true, + scale: null, + type: undefined + }, + total: { + length: null, + memory: { + columnName: 'TOTAL', + dataLength: null, + dataPrecision: 10, + dataScale: 0, + dataType: 'int', + nullable: 1 + }, + precision: 10, + required: false, + scale: 0, + type: undefined + } + } + } ; + }); + + it('should discover schema using `discoverSchema`', function(done) { + ds.discoverSchema('INVENTORY', {}, function(err, schemas) { + if (err) return done(err); + schemas.should.be.eql(schema); + done(); + }); + }); + + it('should callback function, passed as options parameter', function(done) { + var options = function(err, schemas) { + if (err) return done(err); + schemas.should.be.eql(schema); + done(); + }; + + ds.discoverSchema('INVENTORY', options); + }); + + it('should discover schema using `discoverSchema` - promise variant', function(done) { + ds.discoverSchema('INVENTORY', {}) + .then(function(schemas) { + schemas.should.be.eql(schema); + done(); + }) + .catch(function(err){ + done(err); + }); + }); + }); +}); + +describe('discoverModelDefinitions', function(){ + var ds; + before(function(){ + ds = new DataSource({connector: 'memory'}); + + var models = [{type: 'table', name: 'CUSTOMER', owner: 'STRONGLOOP'}, + {type: 'table', name: 'INVENTORY', owner: 'STRONGLOOP'}, + {type: 'table', name: 'LOCATION', owner: 'STRONGLOOP'}]; + + ds.connector.discoverModelDefinitions = function(options, cb) { + process.nextTick(function() { + cb(null, models); + }); + }; + }); + + it('should discover model using `discoverModelDefinitions`', function(done) { + ds.discoverModelDefinitions({}, function(err, schemas) { + if (err) return done(err); + + var tableNames = schemas.map(function(s) { + return s.name; + }); + + tableNames.should.be.eql( + ["CUSTOMER", "INVENTORY", "LOCATION"] + ); + done(); + }); + }); + + it('should callback function, passed as options parameter', function(done) { + var options = function(err, schemas) { + if (err) return done(err); + + var tableNames = schemas.map(function(s) { + return s.name; + }); + + tableNames.should.be.eql( + ["CUSTOMER", "INVENTORY", "LOCATION"] + ); + done(); + }; + + ds.discoverModelDefinitions(options); + }); + + it('should discover model using `discoverModelDefinitions` - promise variant', function(done) { + ds.discoverModelDefinitions({}) + .then(function(schemas) { + var tableNames = schemas.map(function(s) { + return s.name; + }); + + tableNames.should.be.eql( + ["CUSTOMER", "INVENTORY", "LOCATION"] + ); + done(); + }) + .catch(function(err){ + done(err); + }); + }); +}); + +describe('discoverModelProperties', function(){ + var ds; + var modelProperties; + before(function(){ + ds = new DataSource({connector: 'memory'}); + + modelProperties = [{ + owner: 'STRONGLOOP', + tableName: 'INVENTORY', + columnName: 'PRODUCT_ID', + dataType: 'varchar', + dataLength: 20, + dataPrecision: null, + dataScale: null, + nullable: 0 + }, + { + owner: 'STRONGLOOP', + tableName: 'INVENTORY', + columnName: 'LOCATION_ID', + dataType: 'varchar', + dataLength: 20, + dataPrecision: null, + dataScale: null, + nullable: 0 + }, + { + owner: 'STRONGLOOP', + tableName: 'INVENTORY', + columnName: 'AVAILABLE', + dataType: 'int', + dataLength: null, + dataPrecision: 10, + dataScale: 0, + nullable: 1 + }, + { + owner: 'STRONGLOOP', + tableName: 'INVENTORY', + columnName: 'TOTAL', + dataType: 'int', + dataLength: null, + dataPrecision: 10, + dataScale: 0, + nullable: 1 + }]; + + ds.connector.discoverModelProperties = function(modelName, options, cb) { + process.nextTick(function() { + cb(null, modelProperties); + }); + }; + }); + + it('should callback function, passed as options parameter', function(done) { + var options = function(err, schemas) { + if (err) return done(err); + + schemas.should.be.eql(modelProperties); + done(); + }; + + ds.discoverModelProperties('INVENTORY', options); + }); + + it('should discover model metadata using `discoverModelProperties`', function(done) { + ds.discoverModelProperties('INVENTORY', {}, function(err, schemas) { + if (err) return done(err); + + schemas.should.be.eql(modelProperties); + done(); + }); + }); + + it('should discover model metadata using `discoverModelProperties` - promise variant', function(done) { + ds.discoverModelProperties('INVENTORY', {}) + .then(function(schemas) { + schemas.should.be.eql(modelProperties); + done(); + }) + .catch(function(err){ + done(err); + }); + }); +}); + +describe('discoverPrimaryKeys', function(){ + var ds; + var modelProperties; + before(function(){ + ds = new DataSource({connector: 'memory'}); + + primaryKeys = [ + { + owner: 'STRONGLOOP', + tableName: 'INVENTORY', + columnName: 'PRODUCT_ID', + keySeq: 1, + pkName: 'ID_PK' + }, + { + owner: 'STRONGLOOP', + tableName: 'INVENTORY', + columnName: 'LOCATION_ID', + keySeq: 2, + pkName: 'ID_PK' + }]; + + ds.connector.discoverPrimaryKeys = function(modelName, options, cb) { + process.nextTick(function() { + cb(null, primaryKeys); + }); + }; + }); + + it('should discover primary key definitions using `discoverPrimaryKeys`', function(done) { + ds.discoverPrimaryKeys('INVENTORY', {}, function(err, modelPrimaryKeys) { + if (err) return done(err); + + modelPrimaryKeys.should.be.eql(primaryKeys); + done(); + }); + }); + + it('should callback function, passed as options parameter', function(done) { + var options = function(err, modelPrimaryKeys) { + if (err) return done(err); + + modelPrimaryKeys.should.be.eql(primaryKeys); + done(); + }; + ds.discoverPrimaryKeys('INVENTORY', options); + }); + + it('should discover primary key definitions using `discoverPrimaryKeys` - promise variant', function(done) { + ds.discoverPrimaryKeys('INVENTORY', {}) + .then(function(modelPrimaryKeys) { + modelPrimaryKeys.should.be.eql(primaryKeys); + done(); + }) + .catch(function(err){ + done(err); + }); + }); +}); + +describe('discoverForeignKeys', function(){ + var ds; + var modelProperties; + before(function(){ + ds = new DataSource({connector: 'memory'}); + + foreignKeys = [{ + fkOwner: 'STRONGLOOP', + fkName: 'PRODUCT_FK', + fkTableName: 'INVENTORY', + fkColumnName: 'PRODUCT_ID', + keySeq: 1, + pkOwner: 'STRONGLOOP', + pkName: 'PRODUCT_PK', + pkTableName: 'PRODUCT', + pkColumnName: 'ID' + }]; + + ds.connector.discoverForeignKeys = function(modelName, options, cb) { + process.nextTick(function() { + cb(null, foreignKeys); + }); + }; + }); + + it('should discover foreign key definitions using `discoverForeignKeys`', function(done) { + ds.discoverForeignKeys('INVENTORY', {}, function(err, modelForeignKeys) { + if (err) return done(err); + + modelForeignKeys.should.be.eql(foreignKeys); + done(); + }); + }); + + it('should callback function, passed as options parameter', function(done) { + var options = function(err, modelForeignKeys) { + if (err) return done(err); + + modelForeignKeys.should.be.eql(foreignKeys); + done(); + }; + + ds.discoverForeignKeys('INVENTORY', options); + }); + + it('should discover foreign key definitions using `discoverForeignKeys` - promise variant', function(done) { + ds.discoverForeignKeys('INVENTORY', {}) + .then(function(modelForeignKeys) { + modelForeignKeys.should.be.eql(foreignKeys); + done(); + }) + .catch(function(err){ + done(err); + }); + }); +}); + +describe('discoverExportedForeignKeys', function(){ + var ds; + var modelProperties; + before(function(){ + ds = new DataSource({connector: 'memory'}); + + exportedForeignKeys = [{ + fkName: 'PRODUCT_FK', + fkOwner: 'STRONGLOOP', + fkTableName: 'INVENTORY', + fkColumnName: 'PRODUCT_ID', + keySeq: 1, + pkName: 'PRODUCT_PK', + pkOwner: 'STRONGLOOP', + pkTableName: 'PRODUCT', + pkColumnName: 'ID' + }]; + + ds.connector.discoverExportedForeignKeys = function(modelName, options, cb) { + process.nextTick(function() { + cb(null, exportedForeignKeys); + }); + }; + }); + + it('should discover foreign key definitions using `discoverExportedForeignKeys`', function(done) { + ds.discoverExportedForeignKeys('INVENTORY', {}, function(err, modelForeignKeys) { + if (err) return done(err); + + modelForeignKeys.should.be.eql(exportedForeignKeys); + done(); + }); + }); + + it('should callback function, passed as options parameter', function(done) { + var options = function(err, modelForeignKeys) { + if (err) return done(err); + + modelForeignKeys.should.be.eql(exportedForeignKeys); + done(); + }; + + ds.discoverExportedForeignKeys('INVENTORY', options); + }); + + it('should discover foreign key definitions using `discoverExportedForeignKeys` - promise variant', function(done) { + ds.discoverExportedForeignKeys('INVENTORY', {}) + .then(function(modelForeignKeys) { + modelForeignKeys.should.be.eql(exportedForeignKeys); + done(); + }) + .catch(function(err){ + done(err); + }); + }); +}); diff --git a/test/init.js b/test/init.js new file mode 100644 index 0000000..23d6c73 --- /dev/null +++ b/test/init.js @@ -0,0 +1,28 @@ +module.exports = require('should'); + +/* + if (!process.env.TRAVIS) { + if (typeof __cov === 'undefined') { + process.on('exit', function () { + require('semicov').report(); + }); + } + + require('semicov').init('lib'); + } + */ + +var ModelBuilder = require('loopback-model/lib/model-builder').ModelBuilder; +var Schema = require('../').Schema; + +if (!('getSchema' in global)) { + global.getSchema = function (connector, settings) { + return new Schema(connector || 'memory', settings); + }; +} + +if (!('getModelBuilder' in global)) { + global.getModelBuilder = function () { + return new ModelBuilder(); + }; +} diff --git a/test/juggler.js b/test/juggler.js new file mode 100644 index 0000000..aa01b50 --- /dev/null +++ b/test/juggler.js @@ -0,0 +1,9 @@ +var metadata = require('../package.json'); +var project = require('..'); +var should = require('./init.js'); + +describe('juggler', function() { + it('should expose a version number', function() { + project.version.should.equal(metadata.version); + }); +}); diff --git a/test/memory.json b/test/memory.json new file mode 100644 index 0000000..79496d4 --- /dev/null +++ b/test/memory.json @@ -0,0 +1,11 @@ +{ + "ids": { + "User": 4 + }, + "models": { + "User": { + "2": "{\"name\":\"John1\",\"id\":2}", + "3": "{\"name\":\"John3\",\"id\":3}" + } + } +} \ No newline at end of file diff --git a/test/memory.test.js b/test/memory.test.js new file mode 100644 index 0000000..5dbfb87 --- /dev/null +++ b/test/memory.test.js @@ -0,0 +1,987 @@ +var jdb = require('../'); +var DataSource = jdb.DataSource; +var path = require('path'); +var fs = require('fs'); +var assert = require('assert'); +var async = require('async'); +var should = require('./init.js'); +var Memory = require('../lib/connectors/memory').Memory; + +describe('Memory connector', function() { + var file = path.join(__dirname, 'memory.json'); + + function readModels(done) { + fs.readFile(file, function(err, data) { + var json = JSON.parse(data.toString()); + assert(json.models); + assert(json.ids.User); + done(err, json); + }); + } + + before(function(done) { + fs.unlink(file, function(err) { + if (!err || err.code === 'ENOENT') { + done(); + } + }); + }); + + describe('with file', function() { + function createUserModel() { + var ds = new DataSource({ + connector: 'memory', + file: file + }); + + var User = ds.createModel('User', { + id: { + type: Number, + id: true, + generated: true + }, + name: String, + bio: String, + approved: Boolean, + joinedAt: Date, + age: Number + }); + return User; + } + + var User; + var ids = []; + + before(function() { + User = createUserModel(); + }); + + it('should persist create', function(done) { + var count = 0; + async.eachSeries(['John1', 'John2', 'John3'], function(item, cb) { + User.create({name: item}, function(err, result) { + ids.push(result.id); + count++; + readModels(function(err, json) { + assert.equal(Object.keys(json.models.User).length, count); + cb(err); + }); + }); + }, done); + }); + + it('should persist delete', function(done) { + // Now try to delete one + User.deleteById(ids[0], function(err) { + if (err) { + return done(err); + } + readModels(function(err, json) { + if (err) { + return done(err); + } + assert.equal(Object.keys(json.models.User).length, 2); + done(); + }); + }); + }); + + it('should persist upsert', function(done) { + User.upsert({id: ids[1], name: 'John'}, function(err, result) { + if (err) { + return done(err); + } + readModels(function(err, json) { + if (err) { + return done(err); + } + assert.equal(Object.keys(json.models.User).length, 2); + var user = JSON.parse(json.models.User[ids[1]]); + assert.equal(user.name, 'John'); + assert(user.id === ids[1]); + done(); + }); + }); + }); + + it('should persist update', function(done) { + User.update({id: ids[1]}, {name: 'John1'}, + function(err, result) { + if (err) { + return done(err); + } + readModels(function(err, json) { + if (err) { + return done(err); + } + assert.equal(Object.keys(json.models.User).length, 2); + var user = JSON.parse(json.models.User[ids[1]]); + assert.equal(user.name, 'John1'); + assert(user.id === ids[1]); + done(); + }); + }); + }); + + // The saved memory.json from previous test should be loaded + it('should load from the json file', function(done) { + User.find(function(err, users) { + // There should be 2 records + assert.equal(users.length, 2); + done(err); + }); + + }); + }); + + describe('Query for memory connector', function() { + var ds = new DataSource({ + connector: 'memory' + }); + + var User = ds.define('User', { + seq: {type: Number, index: true}, + name: {type: String, index: true, sort: true}, + email: {type: String, index: true}, + birthday: {type: Date, index: true}, + role: {type: String, index: true}, + order: {type: Number, index: true, sort: true}, + vip: {type: Boolean}, + address: { + street: String, + city: String, + state: String, + zipCode: String + }, + friends: [ + { + name: String + } + ] + }); + + before(seed); + it('should allow to find using like', function(done) { + User.find({where: {name: {like: '%St%'}}}, function(err, posts) { + should.not.exist(err); + posts.should.have.property('length', 2); + done(); + }); + }); + + it('should allow to find using like with regexp', function(done) { + User.find({where: {name: {like: /.*St.*/}}}, function(err, posts) { + should.not.exist(err); + posts.should.have.property('length', 2); + done(); + }); + }); + + it('should support like for no match', function(done) { + User.find({where: {name: {like: 'M%XY'}}}, function(err, posts) { + should.not.exist(err); + posts.should.have.property('length', 0); + done(); + }); + }); + + it('should allow to find using nlike', function(done) { + User.find({where: {name: {nlike: '%St%'}}}, function(err, posts) { + should.not.exist(err); + posts.should.have.property('length', 4); + done(); + }); + }); + + it('should allow to find using nlike with regexp', function(done) { + User.find({where: {name: {nlike: /.*St.*/}}}, function(err, posts) { + should.not.exist(err); + posts.should.have.property('length', 4); + done(); + }); + }); + + it('should support nlike for no match', function(done) { + User.find({where: {name: {nlike: 'M%XY'}}}, function(err, posts) { + should.not.exist(err); + posts.should.have.property('length', 6); + done(); + }); + }); + + it('should throw if the like value is not string or regexp', function(done) { + User.find({where: {name: {like: 123}}}, function(err, posts) { + should.exist(err); + done(); + }); + }); + + it('should throw if the nlike value is not string or regexp', function(done) { + User.find({where: {name: {nlike: 123}}}, function(err, posts) { + should.exist(err); + done(); + }); + }); + + it('should throw if the inq value is not an array', function(done) { + User.find({where: {name: {inq: '12'}}}, function(err, posts) { + should.exist(err); + done(); + }); + }); + + it('should throw if the nin value is not an array', function(done) { + User.find({where: {name: {nin: '12'}}}, function(err, posts) { + should.exist(err); + done(); + }); + }); + + it('should throw if the between value is not an array', function(done) { + User.find({where: {name: {between: '12'}}}, function(err, posts) { + should.exist(err); + done(); + }); + }); + + it('should throw if the between value is not an array of length 2', function(done) { + User.find({where: {name: {between: ['12']}}}, function(err, posts) { + should.exist(err); + done(); + }); + }); + + it('should successfully extract 5 users from the db', function(done) { + User.find({where: {seq: {between: [1,5]}}}, function(err, users) { + should(users.length).be.equal(5); + done(); + }); + }); + + it('should successfully extract 1 user (Lennon) from the db', function(done) { + User.find({where: {birthday: {between: [new Date(1970,0),new Date(1990,0)]}}}, + function(err, users) { + should(users.length).be.equal(1); + should(users[0].name).be.equal('John Lennon'); + done(); + }); + }); + + it('should successfully extract 2 users from the db', function(done) { + User.find({where: {birthday: {between: [new Date(1940,0),new Date(1990,0)]}}}, + function(err, users) { + should(users.length).be.equal(2); + done(); + }); + }); + + it('should successfully extract 2 users using implied and', function(done) { + User.find({where: {role:'lead', vip:true}}, function(err, users) { + should(users.length).be.equal(2); + should(users[0].name).be.equal('John Lennon'); + should(users[1].name).be.equal('Paul McCartney'); + done(); + }); + }); + + it('should successfully extract 2 users using implied and & and', function(done) { + User.find({where: { name: 'John Lennon',and: [{role:'lead'}, {vip:true}]}}, function(err, users) { + should(users.length).be.equal(1); + should(users[0].name).be.equal('John Lennon'); + done(); + }); + }); + + it('should successfully extract 2 users using date range', function(done) { + User.find({where: {birthday: {between: + [new Date(1940, 0).toISOString(), new Date(1990, 0).toISOString()]}}}, + function(err, users) { + should(users.length).be.equal(2); + done(); + }); + }); + + it('should successfully extract 0 user from the db', function(done) { + User.find({where: {birthday: {between: [new Date(1990,0), Date.now()]}}}, + function(err, users) { + should(users.length).be.equal(0); + done(); + }); + }); + + it('should successfully extract 2 users matching over array values', function (done) { + User.find({ + where: { + children: { + regexp: /an/ + } + } + }, function (err, users) { + should.not.exist(err); + users.length.should.be.equal(2); + users[0].name.should.be.equal('John Lennon'); + users[1].name.should.be.equal('George Harrison'); + done(); + }); + }); + + it('should successfully extract 1 users matching over array values', function (done) { + User.find({ + where: { + children: 'Dhani' + } + }, function (err, users) { + should.not.exist(err); + users.length.should.be.equal(1); + users[0].name.should.be.equal('George Harrison'); + done(); + }); + }); + + it('should successfully extract 5 users matching a neq filter over array values', function (done) { + User.find({ + where: { + 'children': {neq: 'Dhani'} + } + }, function (err, users) { + should.not.exist(err); + users.length.should.be.equal(5); + done(); + }); + }); + + it('should count using date string', function(done) { + User.count({birthday: {lt: new Date(1990,0).toISOString()}}, + function(err, count) { + should(count).be.equal(2); + done(); + }); + }); + + it('should support order with multiple fields', function(done) { + User.find({order: 'vip ASC, seq DESC'}, function(err, posts) { + should.not.exist(err); + posts[0].seq.should.be.eql(4); + posts[1].seq.should.be.eql(3); + done(); + }); + }); + + it('should sort undefined values to the end when ordered DESC', function(done) { + User.find({order: 'vip ASC, order DESC'}, function(err, posts) { + should.not.exist(err); + + posts[4].seq.should.be.eql(1); + posts[5].seq.should.be.eql(0); + done(); + }); + }); + + it('should throw if order has wrong direction', function(done) { + User.find({order: 'seq ABC'}, function(err, posts) { + should.exist(err); + done(); + }); + }); + + it('should support neq operator for number', function(done) { + User.find({where: {seq: {neq: 4}}}, function(err, users) { + should.not.exist(err); + users.length.should.be.equal(5); + for (var i = 0; i < users.length; i++) { + users[i].seq.should.not.be.equal(4); + } + done(); + }); + }); + + it('should support neq operator for string', function(done) { + User.find({where: {role: {neq: 'lead'}}}, function(err, users) { + should.not.exist(err); + users.length.should.be.equal(4); + for (var i = 0; i < users.length; i++) { + if (users[i].role) { + users[i].role.not.be.equal('lead'); + } + } + done(); + }); + }); + + it('should support neq operator for null', function(done) { + User.find({where: {role: {neq: null}}}, function(err, users) { + should.not.exist(err); + users.length.should.be.equal(2); + for (var i = 0; i < users.length; i++) { + should.exist(users[i].role); + } + done(); + }); + }); + + it('should work when a regex is provided without the regexp operator', + function(done) { + User.find({where: {name: /John.*/i}}, function(err, users) { + should.not.exist(err); + users.length.should.equal(1); + users[0].name.should.equal('John Lennon'); + done(); + }); + }); + + it('should support the regexp operator with regex strings', function(done) { + User.find({where: {name: {regexp: '^J'}}}, function(err, users) { + should.not.exist(err); + users.length.should.equal(1); + users[0].name.should.equal('John Lennon'); + done(); + }); + }); + + it('should support the regexp operator with regex literals', function(done) { + User.find({where: {name: {regexp: /^J/}}}, function(err, users) { + should.not.exist(err); + users.length.should.equal(1); + users[0].name.should.equal('John Lennon'); + done(); + }); + }); + + it('should support the regexp operator with regex objects', function(done) { + User.find({where: {name: {regexp: new RegExp(/^J/)}}}, function(err, + users) { + should.not.exist(err); + users.length.should.equal(1); + users[0].name.should.equal('John Lennon'); + done(); + }); + }); + + it('should support nested property in query', function(done) { + User.find({where: {'address.city': 'San Jose'}}, function(err, users) { + should.not.exist(err); + users.length.should.be.equal(1); + for (var i = 0; i < users.length; i++) { + users[i].address.city.should.be.eql('San Jose'); + } + done(); + }); + }); + + it('should support nested property with regex over arrays in query', function(done) { + User.find({where: {'friends.name': {regexp: /^Ringo/}}}, function(err, users) { + should.not.exist(err); + users.length.should.be.equal(2); + users[0].name.should.be.equal('John Lennon'); + users[1].name.should.be.equal('Paul McCartney'); + done(); + }); + }); + + it('should support nested property with gt in query', function(done) { + User.find({where: {'address.city': {gt: 'San'}}}, function(err, users) { + should.not.exist(err); + users.length.should.be.equal(2); + for (var i = 0; i < users.length; i++) { + users[i].address.state.should.be.eql('CA'); + } + done(); + }); + }); + + it('should support nested property for order in query', function(done) { + User.find({where: {'address.state': 'CA'}, order: 'address.city DESC'}, + function(err, users) { + should.not.exist(err); + users.length.should.be.equal(2); + users[0].address.city.should.be.eql('San Mateo'); + users[1].address.city.should.be.eql('San Jose'); + done(); + }); + }); + + it('should deserialize values after saving in upsert', function(done) { + User.findOne({where: {seq: 1}}, function(err, paul) { + User.updateOrCreate({id: paul.id, name: 'Sir Paul McCartney'}, + function(err, sirpaul) { + should.not.exist(err); + sirpaul.birthday.should.be.instanceOf(Date); + sirpaul.order.should.be.instanceOf(Number); + sirpaul.vip.should.be.instanceOf(Boolean); + done(); + }); + }); + }); + + function seed(done) { + var beatles = [ + { + seq: 0, + name: 'John Lennon', + email: 'john@b3atl3s.co.uk', + role: 'lead', + birthday: new Date('1980-12-08'), + vip: true, + address: { + street: '123 A St', + city: 'San Jose', + state: 'CA', + zipCode: '95131' + }, + friends: [ + { name: 'Paul McCartney' }, + { name: 'George Harrison' }, + { name: 'Ringo Starr' }, + ], + children: ['Sean', 'Julian'] + }, + { + seq: 1, + name: 'Paul McCartney', + email: 'paul@b3atl3s.co.uk', + role: 'lead', + birthday: new Date('1942-06-18'), + order: 1, + vip: true, + address: { + street: '456 B St', + city: 'San Mateo', + state: 'CA', + zipCode: '94065' + }, + friends: [ + { name: 'John Lennon' }, + { name: 'George Harrison' }, + { name: 'Ringo Starr' }, + ], + children: ['Stella', 'Mary', 'Heather', 'Beatrice', 'James'] + }, + {seq: 2, name: 'George Harrison', order: 5, vip: false, children: ['Dhani']}, + {seq: 3, name: 'Ringo Starr', order: 6, vip: false}, + {seq: 4, name: 'Pete Best', order: 4, children: []}, + {seq: 5, name: 'Stuart Sutcliffe', order: 3, vip: true} + ]; + + async.series([ + User.destroyAll.bind(User), + function(cb) { + async.each(beatles, User.create.bind(User), cb); + } + ], done); + } + + }); + + it('should use collection setting', function(done) { + var ds = new DataSource({ + connector: 'memory' + }); + + var Product = ds.createModel('Product', { + name: String + }); + + var Tool = ds.createModel('Tool', { + name: String + }, {memory: {collection: 'Product'}}); + + var Widget = ds.createModel('Widget', { + name: String + }, {memory: {collection: 'Product'}}); + + ds.connector.getCollection('Tool').should.equal('Product'); + ds.connector.getCollection('Widget').should.equal('Product'); + + async.series([ + function(next) { + Tool.create({ name: 'Tool A' }, next); + }, + function(next) { + Tool.create({ name: 'Tool B' }, next); + }, + function(next) { + Widget.create({ name: 'Widget A' }, next); + } + ], function(err) { + Product.find(function(err, products) { + should.not.exist(err); + products.should.have.length(3); + products[0].toObject().should.eql({ name: 'Tool A', id: 1 }); + products[1].toObject().should.eql({ name: 'Tool B', id: 2 }); + products[2].toObject().should.eql({ name: 'Widget A', id: 3 }); + done(); + }); + }); + }); + + describe('automigrate', function() { + var ds; + beforeEach(function() { + ds = new DataSource({ + connector: 'memory' + }); + + ds.createModel('m1', { + name: String + }); + }); + + it('automigrate all models', function(done) { + ds.automigrate(function(err) { + done(err); + }); + }); + + it('automigrate all models - promise variant', function(done) { + ds.automigrate() + .then(function(result) { + done(); + }) + .catch(function(err){ + done(err); + }); + }); + + it('automigrate one model', function(done) { + ds.automigrate('m1', function(err) { + done(err); + }); + }); + + it('automigrate one model - promise variant', function(done) { + ds.automigrate('m1') + .then(function(result) { + done(); + }) + .catch(function(err){ + done(err); + }); + }); + + it('automigrate one or more models in an array', function(done) { + ds.automigrate(['m1'], function(err) { + done(err); + }); + }); + + it('automigrate one or more models in an array - promise variant', function(done) { + ds.automigrate(['m1']) + .then(function(result) { + done(); + }) + .catch(function(err){ + done(err); + }); + }); + + it('automigrate reports errors for models not attached', function(done) { + ds.automigrate(['m1', 'm2'], function(err) { + err.should.be.an.instanceOf(Error); + done(); + }); + }); + + it('automigrate reports errors for models not attached - promise variant', function(done) { + ds.automigrate(['m1', 'm2']) + .then(function(){ + done(new Error('automigrate() should have failed')); + }) + .catch(function(err){ + err.should.be.an.instanceOf(Error); + done(); + }); + }); + + }); + + describe('findOrCreate', function() { + var ds, Cars; + before(function() { + ds = new DataSource({connector: 'memory'}); + Cars = ds.define('Cars', { + color: String + }); + }); + + it('should create a specific object once and in the subsequent calls it should find it', function(done) { + var creationNum = 0; + async.times(100, function(n, next) { + var initialData = {color: 'white'}; + var query = {'where': initialData}; + Cars.findOrCreate(query, initialData, function(err, car, created) { + if (created) creationNum++; + next(err, car); + }); + }, function(err, cars) { + if (err) done(err); + Cars.find(function(err, data) { + if (err) done(err); + data.length.should.equal(1); + data[0].color.should.equal('white'); + creationNum.should.equal(1); + done(); + }); + }); + }); + }); + + + describe('automigrate when NO models are attached', function() { + var ds; + beforeEach(function() { + ds = new DataSource({ + connector: 'memory' + }); + }); + + it('automigrate does NOT report error when NO models are attached', function(done) { + ds.automigrate(function(err) { + done(); + }) + }); + + it('automigrate does NOT report error when NO models are attached - promise variant', function(done) { + ds.automigrate() + .then(done) + .catch(function(err){ + done(err); + }); + }); + }); + + describe('With mocked autoupdate', function() { + var ds, model; + beforeEach(function() { + ds = new DataSource({ + connector: 'memory' + }); + + ds.connector.autoupdate = function(models, cb) { + process.nextTick(cb); + }; + + model = ds.createModel('m1', { + name: String + }); + + ds.automigrate(); + + ds.createModel('m1', { + name: String, + address: String + }); + }); + + it('autoupdates all models', function(done) { + ds.autoupdate(function(err, result){ + done(err); + }); + }); + + it('autoupdates all models - promise variant', function(done) { + ds.autoupdate() + .then(function(result) { + done(); + }) + .catch(function(err){ + done(err); + }); + }); + + it('autoupdates one model', function(done) { + ds.autoupdate('m1', function(err) { + done(err); + }); + }); + + it('autoupdates one model - promise variant', function(done) { + ds.autoupdate('m1') + .then(function(result) { + done(); + }) + .catch(function(err){ + done(err); + }); + }); + + it('autoupdates one or more models in an array', function(done) { + ds.autoupdate(['m1'], function(err) { + done(err); + }); + }); + + it('autoupdates one or more models in an array - promise variant', function(done) { + ds.autoupdate(['m1']) + .then(function(result) { + done(); + }) + .catch(function(err){ + done(err); + }); + }); + + it('autoupdate reports errors for models not attached', function(done) { + ds.autoupdate(['m1', 'm2'], function(err) { + err.should.be.an.instanceOf(Error); + done(); + }); + }); + + it('autoupdate reports errors for models not attached - promise variant', function(done) { + ds.autoupdate(['m1', 'm2']) + .then(function(){ + done(new Error('automigrate() should have failed')); + }) + .catch(function(err){ + err.should.be.an.instanceOf(Error); + done(); + }); + }); + }); +}); + +describe('Optimized connector', function() { + var ds = new DataSource({ connector: Memory }); + + // optimized methods + ds.connector.findOrCreate = function (model, query, data, callback) { + this.all(model, query, {}, function (err, list) { + if (err || (list && list[0])) return callback(err, list && list[0], false); + this.create(model, data, {}, function (err) { + callback(err, data, true); + }); + }.bind(this)); + }; + + require('loopback-model-persistence/test/persistence-hooks.suite')(ds, should); +}); + +describe('Unoptimized connector', function() { + var ds = new DataSource({ connector: Memory }); + // disable optimized methods + ds.connector.updateOrCreate = false; + ds.connector.findOrCreate = false; + + require('loopback-model-persistence/test/persistence-hooks.suite')(ds, should); +}); + +describe('Memory connector with options', function() { + var ds, savedOptions = {}, Post; + + before(function() { + ds = new DataSource({connector: 'memory'}); + ds.connector.create = function(model, data, options, cb) { + savedOptions.create = options; + process.nextTick(function() { + cb(null, 1); + }); + }; + + ds.connector.update = function(model, where, data, options, cb) { + savedOptions.update = options; + process.nextTick(function() { + cb(null, {count: 1}); + }); + }; + + ds.connector.all = function(model, filter, options, cb) { + savedOptions.find = options; + process.nextTick(function() { + cb(null, [{title: 't1', content: 'c1'}]); + }); + }; + + Post = ds.define('Post', { + title: String, + content: String + }); + }); + + it('should receive options from the find method', function(done) { + var opts = {transaction: 'tx1'}; + Post.find({where: {title: 't1'}}, opts, function(err, p) { + savedOptions.find.should.be.eql(opts); + done(err); + }); + }); + + it('should receive options from the find method', function(done) { + var opts = {transaction: 'tx2'}; + Post.find({}, opts, function(err, p) { + savedOptions.find.should.be.eql(opts); + done(err); + }); + }); + + it('should treat first object arg as filter for find', function(done) { + var filter = {title: 't1'}; + Post.find(filter, function(err, p) { + savedOptions.find.should.be.eql({}); + done(err); + }); + }); + + it('should receive options from the create method', function(done) { + var opts = {transaction: 'tx3'}; + Post.create({title: 't1', content: 'c1'}, opts, function(err, p) { + savedOptions.create.should.be.eql(opts); + done(err); + }); + }); + + it('should receive options from the update method', function(done) { + var opts = {transaction: 'tx4'}; + Post.update({title: 't1'}, {content: 'c1 --> c2'}, + opts, function(err, p) { + savedOptions.update.should.be.eql(opts); + done(err); + }); + }); + +}); + +describe('Memory connector with observers', function() { + var ds = new DataSource({ + connector: 'memory' + }); + + it('should have observer mixed into the connector', function() { + ds.connector.observe.should.be.a.function; + ds.connector.notifyObserversOf.should.be.a.function; + }); + + it('should notify observers', function(done) { + var events = []; + ds.connector.execute = function(command, params, options, cb) { + var self = this; + var context = {command: command, params: params, options: options}; + self.notifyObserversOf('before execute', context, function(err) { + process.nextTick(function() { + if (err) return cb(err); + events.push('execute'); + self.notifyObserversOf('after execute', context, function(err) { + cb(err); + }); + }); + }); + }; + + ds.connector.observe('before execute', function(context, next) { + events.push('before execute'); + next(); + }); + + ds.connector.observe('after execute', function(context, next) { + events.push('after execute'); + next(); + }); + + ds.connector.execute('test', [1, 2], {x: 2}, function(err) { + if (err) return done(err); + events.should.eql(['before execute', 'execute', 'after execute']); + done(); + }); + }); +}); + + diff --git a/test/schema.test.js b/test/schema.test.js new file mode 100644 index 0000000..416f9a9 --- /dev/null +++ b/test/schema.test.js @@ -0,0 +1,57 @@ +// This test written in mocha+should.js +var should = require('./init.js'); + +var db = getSchema(), slave = getSchema(), Model, SlaveModel; + +describe('dataSource', function () { + + it('should define Model', function () { + Model = db.define('Model'); + Model.dataSource.should.eql(db); + var m = new Model; + m.getDataSource().should.eql(db); + }); + + it('should clone existing model', function () { + SlaveModel = slave.copyModel(Model); + SlaveModel.dataSource.should.equal(slave); + slave.should.not.equal(db); + var sm = new SlaveModel; + sm.should.be.instanceOf(Model); + sm.getDataSource().should.not.equal(db); + sm.getDataSource().should.equal(slave); + }); + + it('should automigrate', function (done) { + db.automigrate(done); + }); + + it('should create transaction', function (done) { + var tr = db.transaction(); + tr.connected.should.be.false; + tr.connecting.should.be.false; + var called = false; + tr.models.Model.create(Array(3), function () { + called = true; + }); + tr.connected.should.be.false; + tr.connecting.should.be.true; + + db.models.Model.count(function (err, c) { + should.not.exist(err); + should.exist(c); + c.should.equal(0); + called.should.be.false; + tr.exec(function () { + setTimeout(function () { + called.should.be.true; + db.models.Model.count(function (err, c) { + c.should.equal(3); + done(); + }); + }, 100); + }); + }); + }); + +}); diff --git a/test/spec_helper.js b/test/spec_helper.js new file mode 100644 index 0000000..df3ac9d --- /dev/null +++ b/test/spec_helper.js @@ -0,0 +1,50 @@ +/* + if (!process.env.TRAVIS) { + var semicov = require('semicov'); + semicov.init('lib', 'LoopbackData'); + process.on('exit', semicov.report); + } + */ + +var group_name = false, EXT_EXP; +function it(should, test_case) { + check_external_exports(); + if (group_name) { + EXT_EXP[group_name][should] = test_case; + } else { + EXT_EXP[should] = test_case; + } +} + +global.it = it; + +function context(name, tests) { + check_external_exports(); + EXT_EXP[name] = {}; + group_name = name; + tests({ + before: function (f) { + it('setUp', f); + }, + after: function (f) { + it('tearDown', f); + } + }); + group_name = false; +} + +global.context = context; + +exports.init = function init(external_exports) { + EXT_EXP = external_exports; + if (external_exports.done) { + external_exports.done(); + } +}; + +function check_external_exports() { + if (!EXT_EXP) throw new Error( + 'Before run this, please ensure that ' + + 'require("spec_helper").init(exports); called'); +} + diff --git a/test/transient.test.js b/test/transient.test.js new file mode 100644 index 0000000..e3a03f9 --- /dev/null +++ b/test/transient.test.js @@ -0,0 +1,81 @@ +var jdb = require('../'); +var DataSource = jdb.DataSource; +var assert = require('assert'); +var async = require('async'); +var should = require('./init.js'); + +var db, TransientModel, Person, Widget, Item; + +var getTransientDataSource = function(settings) { + return new DataSource('transient', settings); +}; + +describe('Transient connector', function () { + + before(function () { + db = getTransientDataSource(); + TransientModel = db.define('TransientModel', {}, { idInjection: false }); + + Person = TransientModel.extend('Person', {name: String}); + Person.attachTo(db); + + Widget = db.define('Widget', {name: String}); + Item = db.define('Item', { + id: {type: Number, id: true}, name: String + }); + }); + + it('should respect idInjection being false', function(done) { + should.not.exist(Person.definition.properties.id); + should.exist(Person.definition.properties.name); + + Person.create({ name: 'Wilma' }, function(err, inst) { + should.not.exist(err); + inst.toObject().should.eql({ name: 'Wilma' }); + + Person.count(function(err, count) { + should.not.exist(err); + count.should.equal(0); + done(); + }); + }); + }); + + it('should generate a random string id', function(done) { + should.exist(Widget.definition.properties.id); + should.exist(Widget.definition.properties.name); + + Widget.definition.properties.id.type.should.equal(String); + + Widget.create({ name: 'Thing' }, function(err, inst) { + should.not.exist(err); + inst.id.should.match(/^[0-9a-fA-F]{24}$/); + inst.name.should.equal('Thing'); + + Widget.findById(inst.id, function(err, widget) { + should.not.exist(err); + should.not.exist(widget); + done(); + }); + }); + }); + + it('should generate a random number id', function(done) { + should.exist(Item.definition.properties.id); + should.exist(Item.definition.properties.name); + + Item.definition.properties.id.type.should.equal(Number); + + Item.create({ name: 'Example' }, function(err, inst) { + should.not.exist(err); + inst.name.should.equal('Example'); + + Item.count(function(err, count) { + should.not.exist(err); + count.should.equal(0); + done(); + }); + }); + }); + +});