diff --git a/.gitignore b/.gitignore index 8dd1e071..d56edcad 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,4 @@ docs/html docs/man npm-debug.log .project +test/memory.json diff --git a/lib/connector.js b/lib/connector.js index 30acc763..86f0998c 100644 --- a/lib/connector.js +++ b/lib/connector.js @@ -17,6 +17,42 @@ function Connector(name, settings) { */ Connector.prototype.relational = false; +/** + * Get types associated with the connector + * @returns {String[]} The types for the connector + */ +Connector.prototype.getTypes = function() { + return ['db', 'nosql']; +}; + +/** + * Get the default data type for ID + * @returns {Function} The default type for ID + */ +Connector.prototype.getDefaultIdType = function() { + return String; +}; + +/** + * Get the metadata for the connector + * @returns {Object} The metadata object + * @property {String} type The type for the backend + * @property {Function} defaultIdType The default id type + * @property {Boolean} [isRelational] If the connector represents a relational database + * @property {Object} schemaForSettings The schema for settings object + */ +Connector.prototype.getMedadata = function () { + if (!this._metadata) { + this._metadata = { + types: this.getTypes(), + defaultIdType: this.getDefaultIdType(), + isRelational: this.isRelational || (this.getTypes().indexOf('rdbms') !== -1), + schemaForSettings: {} + }; + } + return this._metadata; +}; + /** * Execute a command with given parameters * @param {String} command The command such as SQL @@ -98,6 +134,7 @@ Connector.prototype.defineProperty = function (model, propertyName, propertyDefi */ Connector.prototype.disconnect = function disconnect(cb) { // NO-OP + cb && process.nextTick(cb); }; /** diff --git a/lib/connectors/memory.js b/lib/connectors/memory.js index 72e0e125..1184ac44 100644 --- a/lib/connectors/memory.js +++ b/lib/connectors/memory.js @@ -2,6 +2,8 @@ var util = require('util'); var Connector = require('../connector'); var geo = require('../geo'); var utils = require('../utils'); +var fs = require('fs'); +var async = require('async'); /** * Initialize the Oracle connector against the given data source @@ -10,42 +12,111 @@ var utils = require('../utils'); * @param {Function} [callback] The callback function */ exports.initialize = function initializeDataSource(dataSource, callback) { - dataSource.connector = new Memory(); + dataSource.connector = new Memory(null, dataSource.settings); dataSource.connector.connect(callback); }; exports.Memory = Memory; -function Memory(m) { - if (m) { +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'); + 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'); + 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); + } +}; + +Memory.prototype.loadFromFile = function(callback) { + var self = this; + 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 { + if (data) { + data = JSON.parse(data.toString()); + self.ids = data.ids || {}; + self.cache = data.models || {}; + } else { + if(!self.cache) { + self.ids = {}; + self.cache = {}; + } + } + callback && callback(); + } + }); } else { process.nextTick(callback); } }; +/*! + * Flush the cache into the json file if necessary + * @param {Function} callback + */ +Memory.prototype.saveToFile = function (result, callback) { + var self = this; + if (this.settings.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 { + 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; - this.cache[m] = {}; - this.ids[m] = 1; + if(!this.cache[m]) { + this.cache[m] = {}; + this.ids[m] = 1; + } }; Memory.prototype.create = function create(model, data, callback) { @@ -68,10 +139,11 @@ Memory.prototype.create = function create(model, data, callback) { var idName = this.idName(model); id = (props[idName] && props[idName].type && props[idName].type(id)) || id; this.setIdValue(model, data, id); + if(!this.cache[model]) { + this.cache[model] = {}; + } this.cache[model][id] = JSON.stringify(data); - process.nextTick(function () { - callback(null, id); - }); + this.saveToFile(id, callback); }; Memory.prototype.updateOrCreate = function (model, data, callback) { @@ -90,9 +162,7 @@ Memory.prototype.updateOrCreate = function (model, data, callback) { Memory.prototype.save = function save(model, data, callback) { this.cache[model][this.getIdValue(model, data)] = JSON.stringify(data); - process.nextTick(function () { - callback(null, data); - }); + this.saveToFile(data, callback); }; Memory.prototype.exists = function exists(model, id, callback) { @@ -110,7 +180,7 @@ Memory.prototype.find = function find(model, id, callback) { Memory.prototype.destroy = function destroy(model, id, callback) { delete this.cache[model][id]; - process.nextTick(callback); + this.saveToFile(null, callback); }; Memory.prototype.fromDb = function (model, data) { @@ -273,7 +343,7 @@ Memory.prototype.destroyAll = function destroyAll(model, where, callback) { if (!where) { this.cache[model] = {}; } - process.nextTick(callback); + this.saveToFile(null, callback); }; Memory.prototype.count = function count(model, callback, where) { diff --git a/lib/dao.js b/lib/dao.js index b556e7b1..bfcecb41 100644 --- a/lib/dao.js +++ b/lib/dao.js @@ -126,6 +126,7 @@ DataAccessObject.create = function (data, callback) { function modelCreated() { if (--wait === 0) { callback(gotError ? errors : null, instances); + if(!gotError) instances.forEach(Model.emit.bind('changed')); } } } @@ -168,6 +169,7 @@ DataAccessObject.create = function (data, callback) { saveDone.call(obj, function () { createDone.call(obj, function () { callback(err, obj); + if(!err) Model.emit('changed', obj); }); }); }, obj); @@ -221,7 +223,7 @@ DataAccessObject.upsert = DataAccessObject.updateOrCreate = function upsert(data this.getDataSource().connector.updateOrCreate(Model.modelName, inst.toObject(true), function (err, data) { var obj; if (data) { - inst._initProperties(data, false); + inst._initProperties(data); obj = inst; } else { obj = null; @@ -318,7 +320,7 @@ DataAccessObject.findById = function find(id, cb) { setIdValue(this, data, id); } obj = new this(); - obj._initProperties(data, false); + obj._initProperties(data); } cb(err, obj); }.bind(this)); @@ -540,14 +542,34 @@ DataAccessObject.find = function find(params, cb) { data.forEach(function (d, i) { var obj = new constr(); - obj._initProperties(d, false, params.fields); + obj._initProperties(d, {fields: params.fields}); - if (params && params.include && params.collect) { - data[i] = obj.__cachedRelations[params.collect]; - } else { - data[i] = obj; + if (params && params.include) { + if (params.collect) { + // The collect property indicates that the query is to return the + // standlone items for a related model, not as child of the parent object + // For example, article.tags + obj = obj.__cachedRelations[params.collect]; + } else { + // This handles the case to return parent items including the related + // models. For example, Article.find({include: 'tags'}, ...); + // Try to normalize the include + var includes = params.include || []; + if (typeof includes === 'string') { + includes = [includes]; + } else if (typeof includes === 'object') { + includes = Object.keys(includes); + } + includes.forEach(function (inc) { + // Promote the included model as a direct property + obj.__data[inc] = obj.__cachedRelations[inc]; + }); + delete obj.__data.__cachedRelations; + } } + data[i] = obj; }); + if (data && data.countBeforeLimit) { data.countBeforeLimit = data.countBeforeLimit; } @@ -607,6 +629,7 @@ DataAccessObject.remove = DataAccessObject.deleteAll = DataAccessObject.destroyAll = function destroyAll(where, cb) { if (stillConnecting(this.getDataSource(), this, arguments)) return; + var Model = this; if (!cb && 'function' === typeof where) { cb = where; @@ -615,6 +638,7 @@ DataAccessObject.remove = if (!where) { this.getDataSource().connector.destroyAll(this.modelName, function (err, data) { cb && cb(err, data); + if(!err) Model.emit('deletedAll'); }.bind(this)); } else { // Support an optional where object @@ -622,6 +646,7 @@ DataAccessObject.remove = where = this._coerce(where); this.getDataSource().connector.destroyAll(this.modelName, where, function (err, data) { cb && cb(err, data); + if(!err) Model.emit('deletedAll', where); }.bind(this)); } }; @@ -635,11 +660,13 @@ DataAccessObject.removeById = DataAccessObject.deleteById = DataAccessObject.destroyById = function deleteById(id, cb) { if (stillConnecting(this.getDataSource(), this, arguments)) return; + var Model = this; this.getDataSource().connector.destroy(this.modelName, id, function (err) { if ('function' === typeof cb) { cb(err); } + if(!err) Model.emit('deleted', id); }.bind(this)); }; @@ -684,6 +711,7 @@ setRemoting(DataAccessObject.count, { */ DataAccessObject.prototype.save = function (options, callback) { if (stillConnecting(this.getDataSource(), this, arguments)) return; + var Model = this.constructor; if (typeof options == 'function') { callback = options; @@ -736,10 +764,13 @@ DataAccessObject.prototype.save = function (options, callback) { if (err) { return callback(err, inst); } - inst._initProperties(data, false); + inst._initProperties(data); updateDone.call(inst, function () { saveDone.call(inst, function () { callback(err, inst); + if(!err) { + Model.emit('changed', inst); + } }); }); }); @@ -769,15 +800,18 @@ DataAccessObject.prototype.remove = DataAccessObject.prototype.delete = DataAccessObject.prototype.destroy = function (cb) { if (stillConnecting(this.getDataSource(), this, arguments)) return; + var Model = this.constructor; + var id = getIdValue(this.constructor, this); this.trigger('destroy', function (destroyed) { - this._adapter().destroy(this.constructor.modelName, getIdValue(this.constructor, this), function (err) { + this._adapter().destroy(this.constructor.modelName, id, function (err) { if (err) { return cb(err); } destroyed(function () { if (cb) cb(); + Model.emit('deleted', id); }); }.bind(this)); }); @@ -811,7 +845,8 @@ DataAccessObject.prototype.updateAttributes = function updateAttributes(data, cb if (stillConnecting(this.getDataSource(), this, arguments)) return; var inst = this; - var model = this.constructor.modelName; + var Model = this.constructor + var model = Model.modelName; if (typeof data === 'function') { cb = data; @@ -850,7 +885,8 @@ DataAccessObject.prototype.updateAttributes = function updateAttributes(data, cb } done.call(inst, function () { saveDone.call(inst, function () { - cb(err, inst); + if(cb) cb(err, inst); + if(!err) Model.emit('changed', inst); }); }); }); diff --git a/lib/datasource.js b/lib/datasource.js index 8f11368c..356a58c9 100644 --- a/lib/datasource.js +++ b/lib/datasource.js @@ -424,17 +424,28 @@ DataSource.prototype.defineRelations = function (modelClass, relations) { /*! * Set up the data access functions from the data source - * @param modelClass - * @param settings + * @param {Model} modelClass The model class + * @param {Object} settings The settings object */ DataSource.prototype.setupDataAccess = function (modelClass, settings) { - if (this.connector && this.connector.define) { - // pass control to connector - this.connector.define({ - model: modelClass, - properties: modelClass.definition.properties, - settings: 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.properties[idName].type = idType; + } + if (this.connector.define) { + // pass control to connector + this.connector.define({ + model: modelClass, + properties: modelClass.definition.properties, + settings: settings + }); + } } // add data access objects @@ -559,14 +570,55 @@ DataSource.prototype.mixin = function (ModelCtor) { }); }; +/** + * @see ModelBuilder.prototype.getModel + */ DataSource.prototype.getModel = function (name, forceCreate) { return this.modelBuilder.getModel(name, forceCreate); }; +/** + * @see ModelBuilder.prototype.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 types = this.connector && this.connector.getTypes() || []; + if (typeof types === 'string') { + types = types.split(/[\s,\/]+/); + } + return types; +}; + +/** + * Check the data source supports the given types + * @param String|String[]) types A type name or an array of type names + * @return {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. * diff --git a/lib/include.js b/lib/include.js index 0bd2d557..4985ff2a 100644 --- a/lib/include.js +++ b/lib/include.js @@ -1,3 +1,7 @@ +var utils = require('./utils'); +var isPlainObject = utils.isPlainObject; +var defineCachedRelations = utils.defineCachedRelations; + /** * Include mixin for ./model.js */ @@ -29,8 +33,8 @@ Inclusion.include = function (objects, include, cb) { var self = this; if ( - (include.constructor.name == 'Array' && include.length == 0) || - (include.constructor.name == 'Object' && Object.keys(include).length == 0) + !include || (Array.isArray(include) && include.length === 0) || + (isPlainObject(include) && Object.keys(include).length === 0) ) { cb(null, objects); return; @@ -48,7 +52,7 @@ Inclusion.include = function (objects, include, cb) { nbCallbacks++; callback(function () { nbCallbacks--; - if (nbCallbacks == 0) { + if (nbCallbacks === 0) { cb(null, objects); } }); @@ -61,7 +65,7 @@ Inclusion.include = function (objects, include, cb) { if (typeof ij === 'string') { ij = [ij]; } - if (ij.constructor.name === 'Object') { + if (isPlainObject(ij)) { var newIj = []; for (var key in ij) { var obj = {}; @@ -76,12 +80,13 @@ Inclusion.include = function (objects, include, cb) { function processIncludeItem(objs, include, keyVals, objsByKeys) { var relations = self.relations; - if (include.constructor.name === 'Object') { - var relationName = Object.keys(include)[0]; - var subInclude = include[relationName]; + var relationName, subInclude; + if (isPlainObject(include)) { + relationName = Object.keys(include)[0]; + subInclude = include[relationName]; } else { - var relationName = include; - var subInclude = []; + relationName = include; + subInclude = []; } var relation = relations[relationName]; @@ -89,7 +94,7 @@ Inclusion.include = function (objects, include, cb) { return function () { cb(new Error('Relation "' + relationName + '" is not defined for ' + self.modelName + ' model')); - } + }; } var req = {'where': {}}; @@ -117,18 +122,17 @@ Inclusion.include = function (objects, include, cb) { } } - req['where'][relation.keyTo] = {inq: inValues}; - req['include'] = subInclude; + req.where[relation.keyTo] = {inq: inValues}; + req.include = subInclude; return function (cb) { relation.modelTo.find(req, function (err, objsIncluded) { + var objectsFrom, j; for (var i = 0; i < objsIncluded.length; i++) { delete keysToBeProcessed[objsIncluded[i][relation.keyTo]]; - var objectsFrom = objsByKeys[relation.keyFrom][objsIncluded[i][relation.keyTo]]; - for (var j = 0; j < objectsFrom.length; j++) { - if (!objectsFrom[j].__cachedRelations) { - objectsFrom[j].__cachedRelations = {}; - } + objectsFrom = objsByKeys[relation.keyFrom][objsIncluded[i][relation.keyTo]]; + for (j = 0; j < objectsFrom.length; j++) { + defineCachedRelations(objectsFrom[j]); if (relation.multiple) { if (!objectsFrom[j].__cachedRelations[relationName]) { objectsFrom[j].__cachedRelations[relationName] = []; @@ -142,11 +146,9 @@ Inclusion.include = function (objects, include, cb) { // No relation have been found for these keys for (var key in keysToBeProcessed) { - var objectsFrom = objsByKeys[relation.keyFrom][key]; - for (var j = 0; j < objectsFrom.length; j++) { - if (!objectsFrom[j].__cachedRelations) { - objectsFrom[j].__cachedRelations = {}; - } + objectsFrom = objsByKeys[relation.keyFrom][key]; + for (j = 0; j < objectsFrom.length; j++) { + defineCachedRelations(objectsFrom[j]); objectsFrom[j].__cachedRelations[relationName] = relation.multiple ? [] : null; } @@ -158,5 +160,5 @@ Inclusion.include = function (objects, include, cb) { return null; } -} +}; diff --git a/lib/model-builder.js b/lib/model-builder.js index ab8aa1b2..a5076488 100644 --- a/lib/model-builder.js +++ b/lib/model-builder.js @@ -71,6 +71,11 @@ ModelBuilder.prototype.getModel = function (name, forceCreate) { return model; }; +/** + * Get the model definition by name + * @param {String} name The model name + * @returns {ModelDefinition} The model definition + */ ModelBuilder.prototype.getModelDefinition = function (name) { return this.definitions[name]; }; @@ -151,17 +156,14 @@ ModelBuilder.prototype.define = function defineClass(className, properties, sett // TODO: [rfeng] We need to decide what names to use for built-in models such as User. if (!ModelClass || !ModelClass.settings.unresolved) { // every class can receive hash of data as optional param - ModelClass = function ModelConstructor(data, dataSource) { + ModelClass = function ModelConstructor(data, options) { if (!(this instanceof ModelConstructor)) { - return new ModelConstructor(data, dataSource); + return new ModelConstructor(data, options); } if (ModelClass.settings.unresolved) { throw new Error('Model ' + ModelClass.modelName + ' is not defined.'); } ModelBaseClass.apply(this, arguments); - if (dataSource) { - hiddenProperty(this, '__dataSource', dataSource); - } }; // mix in EventEmitter (don't inherit from) var events = new EventEmitter(); @@ -338,16 +340,6 @@ ModelBuilder.prototype.define = function defineClass(className, properties, sett if (!DataType) { throw new Error('Invalid type for property ' + propertyName); } - if (Array.isArray(DataType) || DataType === Array) { - DataType = List; - } else if (DataType.name === 'Date') { - var OrigDate = Date; - DataType = function Date(arg) { - return new OrigDate(arg); - }; - } else if (typeof DataType === 'string') { - DataType = modelBuilder.resolveType(DataType); - } if (prop.required) { var requiredOptions = typeof prop.required === 'object' ? prop.required : undefined; @@ -363,6 +355,17 @@ ModelBuilder.prototype.define = function defineClass(className, properties, sett } }, set: function (value) { + var DataType = ModelClass.definition.properties[propertyName].type; + if (Array.isArray(DataType) || DataType === Array) { + DataType = List; + } else if (DataType.name === 'Date') { + var OrigDate = Date; + DataType = function Date(arg) { + return new OrigDate(arg); + }; + } else if (typeof DataType === 'string') { + DataType = modelBuilder.resolveType(DataType); + } if (ModelClass.setter[propertyName]) { ModelClass.setter[propertyName].call(this, value); // Try setter first } else { diff --git a/lib/model.js b/lib/model.js index 58211b14..258d1390 100644 --- a/lib/model.js +++ b/lib/model.js @@ -27,8 +27,13 @@ var BASE_TYPES = ['String', 'Boolean', 'Number', 'Date', 'Text']; * @constructor * @param {Object} data - initial object data */ -function ModelBaseClass(data) { - this._initProperties(data, true); +function ModelBaseClass(data, options) { + options = options || {}; + if(!('applySetters' in options)) { + // Default to true + options.applySetters = true; + } + this._initProperties(data, options); } // FIXME: [rfeng] We need to make sure the input data should not be mutated. Disabled cloning for now to get tests passing @@ -42,19 +47,29 @@ function clone(data) { */ return data; } + /** - * Initialize properties - * @param data - * @param applySetters + * Initialize the model instance with a list of properties + * @param {Object} data The data object + * @param {Object} options An object to control the instantiation + * @property {Boolean} applySetters Controls if the setters will be applied + * @property {Boolean} strict Set the instance level strict mode * @private */ -ModelBaseClass.prototype._initProperties = function (data, applySetters) { +ModelBaseClass.prototype._initProperties = function (data, options) { var self = this; var ctor = this.constructor; var properties = ctor.definition.build(); data = data || {}; + options = options || {}; + var applySetters = options.applySetters; + var strict = options.strict; + + if(strict === undefined) { + strict = ctor.definition.settings.strict; + } Object.defineProperty(this, '__cachedRelations', { writable: true, enumerable: false, @@ -76,15 +91,32 @@ ModelBaseClass.prototype._initProperties = function (data, applySetters) { value: {} }); - if (data['__cachedRelations']) { - this.__cachedRelations = data['__cachedRelations']; + /** + * Instance level data source + */ + Object.defineProperty(this, '__dataSource', { + writable: true, + enumerable: false, + configurable: true, + value: options.dataSource + }); + + /** + * Instance level strict mode + */ + Object.defineProperty(this, '__strict', { + writable: true, + enumerable: false, + configurable: true, + value: strict + }); + + if (data.__cachedRelations) { + this.__cachedRelations = data.__cachedRelations; } - // Check if the strict option is set to false for the model - var strict = ctor.definition.settings.strict; - for (var i in data) { - if (i in properties) { + if (i in properties && typeof data[i] !== 'function') { this.__data[i] = this.__dataWas[i] = clone(data[i]); } else if (i in ctor.relations) { this.__data[ctor.relations[i].keyFrom] = this.__dataWas[i] = data[i][ctor.relations[i].keyTo]; @@ -100,7 +132,7 @@ ModelBaseClass.prototype._initProperties = function (data, applySetters) { if (applySetters === true) { for (var propertyName in data) { - if ((propertyName in properties) || (propertyName in ctor.relations)) { + if (typeof data[propertyName] !== 'function' && ((propertyName in properties) || (propertyName in ctor.relations))) { self[propertyName] = self.__data[propertyName] || data[propertyName]; } } @@ -109,7 +141,7 @@ ModelBaseClass.prototype._initProperties = function (data, applySetters) { // Set the unknown properties as properties to the object if (strict === false) { for (var propertyName in data) { - if (!(propertyName in properties)) { + if (typeof data[propertyName] !== 'function' && !(propertyName in properties)) { self[propertyName] = self.__data[propertyName] || data[propertyName]; } } @@ -117,7 +149,7 @@ ModelBaseClass.prototype._initProperties = function (data, applySetters) { ctor.forEachProperty(function (propertyName) { - if ('undefined' === typeof self.__data[propertyName]) { + if (undefined === self.__data[propertyName]) { self.__data[propertyName] = self.__dataWas[propertyName] = getDefault(propertyName); } else { self.__dataWas[propertyName] = self.__data[propertyName]; @@ -160,7 +192,7 @@ ModelBaseClass.prototype._initProperties = function (data, applySetters) { } this.trigger('initialize'); -} +}; /** * @param {String} prop - property name @@ -197,18 +229,23 @@ ModelBaseClass.toString = function () { }; /** - * Convert instance to Object + * Convert model instance to a plain JSON object * - * @param {Boolean} onlySchema - restrict properties to dataSource only, default false - * when onlySchema == true, only properties defined in dataSource returned, - * otherwise all enumerable properties returned + * @param {Boolean} onlySchema - restrict properties to dataSource only, + * default to false. When onlySchema is true, only properties defined in + * the schema are returned, otherwise all enumerable properties returned * @returns {Object} - canonical object representation (no getters and setters) */ ModelBaseClass.prototype.toObject = function (onlySchema) { + if(onlySchema === undefined) { + onlySchema = true; + } var data = {}; var self = this; - var schemaLess = this.constructor.definition.settings.strict === false || !onlySchema; + var strict = this.__strict; + var schemaLess = (strict === false) || !onlySchema; + this.constructor.forEachProperty(function (propertyName) { if (self[propertyName] instanceof List) { data[propertyName] = self[propertyName].toObject(!schemaLess); @@ -223,10 +260,25 @@ ModelBaseClass.prototype.toObject = function (onlySchema) { } }); + var val = null; if (schemaLess) { - for (var propertyName in self.__data) { + // Find its own properties which can be set via myModel.myProperty = 'myValue'. + // If the property is not declared in the model definition, no setter will be + // triggered to add it to __data + for (var propertyName in self) { + if(self.hasOwnProperty(propertyName) && (!data.hasOwnProperty(propertyName))) { + val = self[propertyName]; + if (val !== undefined && val !== null && val.toObject) { + data[propertyName] = val.toObject(!schemaLess); + } else { + data[propertyName] = val; + } + } + } + // Now continue to check __data + for (propertyName in self.__data) { if (!data.hasOwnProperty(propertyName)) { - var val = self.hasOwnProperty(propertyName) ? self[propertyName] : self.__data[propertyName]; + val = self.hasOwnProperty(propertyName) ? self[propertyName] : self.__data[propertyName]; if (val !== undefined && val !== null && val.toObject) { data[propertyName] = val.toObject(!schemaLess); } else { @@ -238,13 +290,8 @@ ModelBaseClass.prototype.toObject = function (onlySchema) { return data; }; -// ModelBaseClass.prototype.hasOwnProperty = function (prop) { -// return this.__data && this.__data.hasOwnProperty(prop) || -// Object.getOwnPropertyNames(this).indexOf(prop) !== -1; -// }; - ModelBaseClass.prototype.toJSON = function () { - return this.toObject(); + return this.toObject(false); }; ModelBaseClass.prototype.fromObject = function (obj) { @@ -291,10 +338,15 @@ ModelBaseClass.mixin = function (anotherClass, options) { ModelBaseClass.prototype.getDataSource = function () { return this.__dataSource || this.constructor.dataSource; -} +}; + ModelBaseClass.getDataSource = function () { return this.dataSource; -} +}; + +ModelBaseClass.prototype.setStrict = function (strict) { + this.__strict = strict; +}; jutil.mixin(ModelBaseClass, Hookable); jutil.mixin(ModelBaseClass, validations.Validatable); diff --git a/lib/relations.js b/lib/relations.js index d3588168..a5372f92 100644 --- a/lib/relations.js +++ b/lib/relations.js @@ -18,6 +18,18 @@ Relation.relationNameFor = function relationNameFor(foreignKey) { } }; +function lookupModel(models, modelName) { + if(models[modelName]) { + return models[modelName]; + } + var lookupClassName = modelName.toLowerCase(); + for (var name in models) { + if (name.toLowerCase() === lookupClassName) { + return models[name]; + } + } +} + /** * Declare hasMany relation * @@ -34,11 +46,7 @@ Relation.hasMany = function hasMany(anotherClass, params) { anotherClass = params.model; } else { var anotherClassName = i8n.singularize(anotherClass).toLowerCase(); - for (var name in this.dataSource.modelBuilder.models) { - if (name.toLowerCase() === anotherClassName) { - anotherClass = this.dataSource.modelBuilder.models[name]; - } - } + anotherClass = lookupModel(this.dataSource.modelBuilder.models, anotherClassName); } } var methodName = params.as || i8n.camelize(anotherClass.pluralModelName, true); @@ -130,9 +138,13 @@ Relation.hasMany = function hasMany(anotherClass, params) { function find(id, cb) { anotherClass.findById(id, function (err, inst) { - if (err) return cb(err); - if (!inst) return cb(new Error('Not found')); - if (inst[fk] && inst[fk].toString() == this[idName].toString()) { + if (err) { + return cb(err); + } + if (!inst) { + return cb(new Error('Not found')); + } + if (inst[fk] && inst[fk].toString() === this[idName].toString()) { cb(null, inst); } else { cb(new Error('Permission denied')); @@ -143,9 +155,13 @@ Relation.hasMany = function hasMany(anotherClass, params) { function destroy(id, cb) { var self = this; anotherClass.findById(id, function (err, inst) { - if (err) return cb(err); - if (!inst) return cb(new Error('Not found')); - if (inst[fk] && inst[fk].toString() == self[idName].toString()) { + if (err) { + return cb(err); + } + if (!inst) { + return cb(new Error('Not found')); + } + if (inst[fk] && inst[fk].toString() === self[idName].toString()) { inst.destroy(cb); } else { cb(new Error('Permission denied')); @@ -186,11 +202,7 @@ Relation.belongsTo = function (anotherClass, params) { anotherClass = params.model; } else { var anotherClassName = anotherClass.toLowerCase(); - for (var name in this.dataSource.modelBuilder.models) { - if (name.toLowerCase() === anotherClassName) { - anotherClass = this.dataSource.modelBuilder.models[name]; - } - } + anotherClass = lookupModel(this.dataSource.modelBuilder.models, anotherClassName); } } @@ -207,16 +219,20 @@ Relation.belongsTo = function (anotherClass, params) { }; this.dataSource.defineForeignKey(this.modelName, fk, anotherClass.modelName); - this.prototype['__finders__'] = this.prototype['__finders__'] || {}; + this.prototype.__finders__ = this.prototype.__finders__ || {}; - this.prototype['__finders__'][methodName] = function (id, cb) { + this.prototype.__finders__[methodName] = function (id, cb) { if (id === null) { cb(null, null); return; } anotherClass.findById(id, function (err, inst) { - if (err) return cb(err); - if (!inst) return cb(null, null); + if (err) { + return cb(err); + } + if (!inst) { + return cb(null, null); + } if (inst[idName] === this[fk]) { cb(null, inst); } else { @@ -234,7 +250,7 @@ Relation.belongsTo = function (anotherClass, params) { } var self = this; var cachedValue; - if (!refresh && this.__cachedRelations && (typeof this.__cachedRelations[methodName] !== 'undefined')) { + if (!refresh && this.__cachedRelations && (this.__cachedRelations[methodName] !== undefined)) { cachedValue = this.__cachedRelations[methodName]; } if (p instanceof ModelBaseClass) { // acts as setter @@ -277,7 +293,7 @@ Relation.hasAndBelongsToMany = function hasAndBelongsToMany(anotherClass, params if (params.model) { anotherClass = params.model; } else { - anotherClass = lookupModel(i8n.singularize(anotherClass)) || + anotherClass = lookupModel(models, i8n.singularize(anotherClass).toLowerCase()) || anotherClass; } if (typeof anotherClass === 'string') { @@ -288,7 +304,7 @@ Relation.hasAndBelongsToMany = function hasAndBelongsToMany(anotherClass, params if (!params.through) { var name1 = this.modelName + anotherClass.modelName; var name2 = anotherClass.modelName + this.modelName; - params.through = lookupModel(name1) || lookupModel(name2) || + params.through = lookupModel(models, name1) || lookupModel(models, name2) || this.dataSource.define(name1); } params.through.belongsTo(this); @@ -296,13 +312,4 @@ Relation.hasAndBelongsToMany = function hasAndBelongsToMany(anotherClass, params this.hasMany(anotherClass, {as: params.as, through: params.through}); - function lookupModel(modelName) { - var lookupClassName = modelName.toLowerCase(); - for (var name in models) { - if (name.toLowerCase() === lookupClassName) { - return models[name]; - } - } - } - }; diff --git a/lib/scope.js b/lib/scope.js index d37d97c5..a54aad37 100644 --- a/lib/scope.js +++ b/lib/scope.js @@ -1,3 +1,5 @@ +var utils = require('./utils'); +var defineCachedRelations = utils.defineCachedRelations; /** * Module exports */ @@ -54,14 +56,12 @@ function defineScope(cls, targetClass, name, params, methods) { throw new Error('Method can be only called with one or two arguments'); } - if (!this.__cachedRelations || (typeof this.__cachedRelations[name] == 'undefined') || actualRefresh) { + if (!this.__cachedRelations || (this.__cachedRelations[name] === undefined) || actualRefresh) { var self = this; var params = mergeParams(actualCond, caller._scope); return targetClass.find(params, function (err, data) { if (!err && saveOnCache) { - if (!self.__cachedRelations) { - self.__cachedRelations = {}; - } + defineCachedRelations(self); self.__cachedRelations[name] = data; } cb(err, data); diff --git a/lib/sql.js b/lib/sql.js index b3b5c664..3f32882b 100644 --- a/lib/sql.js +++ b/lib/sql.js @@ -19,6 +19,22 @@ util.inherits(BaseSQL, Connector); */ BaseSQL.prototype.relational = true; +/** + * Get types associated with the connector + * @returns {String[]} The types for the connector + */ + BaseSQL.prototype.getTypes = function() { + return ['db', 'rdbms', 'sql']; +}; + +/*! + * Get the default data type for ID + * @returns {Function} + */ +BaseSQL.prototype.getDefaultIdType = function() { + return Number; +}; + BaseSQL.prototype.query = function () { throw new Error('query method should be declared in connector'); }; diff --git a/lib/utils.js b/lib/utils.js index 08804dda..520cf462 100644 --- a/lib/utils.js +++ b/lib/utils.js @@ -4,6 +4,8 @@ exports.selectFields = selectFields; exports.removeUndefined = removeUndefined; exports.parseSettings = parseSettings; exports.mergeSettings = mergeSettings; +exports.isPlainObject = isPlainObject; +exports.defineCachedRelations = defineCachedRelations; var traverse = require('traverse'); @@ -176,4 +178,29 @@ function mergeSettings(target, src) { } return dst; +} + +/** + * Define an non-enumerable __cachedRelations property + * @param {Object} obj The obj to receive the __cachedRelations + */ +function defineCachedRelations(obj) { + if (!obj.__cachedRelations) { + Object.defineProperty(obj, '__cachedRelations', { + writable: true, + enumerable: false, + configurable: true, + value: {} + }); + } +} + +/** + * Check if the argument is plain object + * @param {*) obj The obj value + * @returns {boolean} + */ +function isPlainObject(obj) { + return (typeof obj === 'object') && (obj !== null) + && (obj.constructor === Object); } \ No newline at end of file diff --git a/package.json b/package.json index a2ec7e96..ba76c34a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "loopback-datasource-juggler", - "version": "1.2.13", + "version": "1.3.0", "description": "LoopBack DataSoure Juggler", "keywords": [ "StrongLoop", @@ -27,10 +27,10 @@ "mocha": "~1.12.1" }, "dependencies": { - "async": "~0.2.9", - "inflection": "~1.2.6", - "traverse": "~0.6.5", - "qs": "~0.6.5" + "async": "~0.2.10", + "inflection": "~1.3.3", + "traverse": "~0.6.6", + "qs": "~0.6.6" }, "license": "MIT" } diff --git a/test/events.js b/test/events.js new file mode 100644 index 00000000..b2867591 --- /dev/null +++ b/test/events.js @@ -0,0 +1,81 @@ +var should = require('./init.js'); + +describe('events', function() { + beforeEach(function(done) { + var test = this; + this.db = getSchema(); + this.TestModel = this.db.define('TestModel'); + this.db.automigrate(function(err) { + if(err) return done(err); + test.TestModel.create(function(err, inst) { + if(err) return done(err); + test.inst = inst; + done(); + }); + }); + this.shouldEmitEvent = function(eventName, listener, done) { + var timeout = setTimeout(function() { + done(new Error('did not emit ' + eventName)); + }, 100); + this.TestModel.on(eventName, function() { + clearTimeout(timeout); + listener.apply(this, arguments); + done(); + }); + } + }); + + describe('changed', function() { + it('should be emitted after save', function(done) { + var model = new this.TestModel({name: 'foobar'}); + this.shouldEmitEvent('changed', assertValidChangedArgs, done); + model.save(); + }); + it('should be emitted after upsert', function(done) { + this.shouldEmitEvent('changed', assertValidChangedArgs, done); + this.TestModel.upsert({name: 'batbaz'}); + }); + it('should be emitted after create', function(done) { + this.shouldEmitEvent('changed', assertValidChangedArgs, done); + this.TestModel.create({name: '...'}); + }); + it('should be emitted after updateAttributes', function(done) { + var test = this; + this.TestModel.create({name: 'bazzy'}, function(err, model) { + // prevent getting the changed event from "create" + process.nextTick(function() { + test.shouldEmitEvent('changed', assertValidChangedArgs, done); + model.updateAttributes({name: 'foo'}); + }); + }); + }); + }); + + describe('deleted', function() { + it('should be emitted after destroy', function(done) { + this.shouldEmitEvent('deleted', assertValidDeletedArgs, done); + this.inst.destroy(); + }); + it('should be emitted after deleteById', function(done) { + this.shouldEmitEvent('deleted', assertValidDeletedArgs, done); + this.TestModel.deleteById(this.inst.id); + }); + }); + + describe('deletedAll', function() { + it('should be emitted after destroyAll', function(done) { + this.shouldEmitEvent('deletedAll', function(where) { + where.name.should.equal('foo'); + }, done); + this.TestModel.destroyAll({name: 'foo'}); + }); + }); +}); + +function assertValidChangedArgs(obj) { + obj.should.have.property('id'); +} + +function assertValidDeletedArgs(id) { + id.should.be.ok; +} \ No newline at end of file diff --git a/test/hooks.test.js b/test/hooks.test.js index 2610f1b3..f613122b 100644 --- a/test/hooks.test.js +++ b/test/hooks.test.js @@ -402,6 +402,8 @@ describe('hooks', function () { }); }); + + function addHooks(name, done) { var called = false, random = String(Math.floor(Math.random() * 1000)); User['before' + name] = function (next, data) { diff --git a/test/include.test.js b/test/include.test.js index 6e265858..8b26ee71 100644 --- a/test/include.test.js +++ b/test/include.test.js @@ -13,6 +13,12 @@ describe('include', function () { passports.length.should.be.ok; passports.forEach(function (p) { p.__cachedRelations.should.have.property('owner'); + + // The relation should be promoted as the 'owner' property + p.should.have.property('owner'); + // The __cachedRelations should be removed from json output + p.toJSON().should.not.have.property('__cachedRelations'); + var owner = p.__cachedRelations.owner; if (!p.ownerId) { should.not.exist(owner); @@ -31,6 +37,11 @@ describe('include', function () { should.exist(users); users.length.should.be.ok; users.forEach(function (u) { + // The relation should be promoted as the 'owner' property + u.should.have.property('posts'); + // The __cachedRelations should be removed from json output + u.toJSON().should.not.have.property('__cachedRelations'); + u.__cachedRelations.should.have.property('posts'); u.__cachedRelations.posts.forEach(function (p) { p.userId.should.equal(u.id); @@ -47,6 +58,12 @@ describe('include', function () { passports.length.should.be.ok; passports.forEach(function (p) { p.__cachedRelations.should.have.property('owner'); + + // The relation should be promoted as the 'owner' property + p.should.have.property('owner'); + // The __cachedRelations should be removed from json output + p.toJSON().should.not.have.property('__cachedRelations'); + var user = p.__cachedRelations.owner; if (!p.ownerId) { should.not.exist(user); @@ -97,6 +114,12 @@ describe('include', function () { should.exist(users); users.length.should.be.ok; users.forEach(function (user) { + // The relation should be promoted as the 'owner' property + user.should.have.property('posts'); + user.should.have.property('passports'); + // The __cachedRelations should be removed from json output + user.toJSON().should.not.have.property('__cachedRelations'); + user.__cachedRelations.should.have.property('posts'); user.__cachedRelations.should.have.property('passports'); user.__cachedRelations.posts.forEach(function (p) { diff --git a/test/loopback-dl.test.js b/test/loopback-dl.test.js index 205ee4e1..8be22fac 100644 --- a/test/loopback-dl.test.js +++ b/test/loopback-dl.test.js @@ -46,11 +46,35 @@ describe('ModelBuilder define model', function () { User.modelName.should.equal('User'); user.should.be.a('object'); - assert(user.name === 'Joe'); - assert(user.age === undefined); - assert(user.toObject().age === undefined); - assert(user.toObject(true).age === undefined); - assert(user.bio === undefined); + user.should.have.property('name', 'Joe'); + user.should.not.have.property('age'); + user.toObject().should.not.have.property('age'); + user.toObject(true).should.not.have.property('age'); + user.should.not.have.property('bio'); + done(null, User); + }); + + it('should ignore non-predefined properties in strict mode', function (done) { + var modelBuilder = new ModelBuilder(); + + var User = modelBuilder.define('User', {name: String, bio: String}, {strict: true}); + + var user = new User({name: 'Joe'}); + user.age = 10; + user.bio = 'me'; + + user.should.have.property('name', 'Joe'); + user.should.have.property('bio', 'me'); + + // Non predefined property age should be ignored in strict mode if schemaOnly parameter is not false + user.toObject().should.not.have.property('age'); + user.toObject(true).should.not.have.property('age'); + user.toObject(false).should.have.property('age', 10); + + // Predefined property bio should be kept in strict mode + user.toObject().should.have.property('bio', 'me'); + user.toObject(true).should.have.property('bio', 'me'); + user.toObject(false).should.have.property('bio', 'me'); done(null, User); }); @@ -83,6 +107,31 @@ describe('ModelBuilder define model', function () { done(null, User); }); + it('should take non-predefined properties in non-strict mode', function (done) { + var modelBuilder = new ModelBuilder(); + + var User = modelBuilder.define('User', {name: String, bio: String}, {strict: false}); + + var user = new User({name: 'Joe'}); + user.age = 10; + user.bio = 'me'; + + user.should.have.property('name', 'Joe'); + user.should.have.property('bio', 'me'); + + // Non predefined property age should be kept in non-strict mode + user.toObject().should.have.property('age', 10); + user.toObject(true).should.have.property('age', 10); + user.toObject(false).should.have.property('age', 10); + + // Predefined property bio should be kept + user.toObject().should.have.property('bio', 'me'); + user.toObject(true).should.have.property('bio', 'me'); + user.toObject(false).should.have.property('bio', 'me'); + + done(null, User); + }); + it('should use false as the default value for strict', function (done) { var modelBuilder = new ModelBuilder(); @@ -416,6 +465,61 @@ describe('DataSource define model', function () { }); }); + it('supports instance level strict mode', function () { + var ds = new DataSource('memory'); + + var User = ds.define('User', {name: String, bio: String}, {strict: true}); + + var user = new User({name: 'Joe', age: 20}, {strict: false}); + + user.should.have.property('__strict', false); + user.should.be.a('object'); + user.should.have.property('name', 'Joe'); + user.should.have.property('age', 20); + user.toObject().should.have.property('age', 20); + user.toObject(true).should.have.property('age', 20); + + user.setStrict(true); + user.toObject().should.not.have.property('age'); + user.toObject(true).should.not.have.property('age'); + user.toObject(false).should.have.property('age', 20); + + }); + + it('injects id by default', function (done) { + var ds = new ModelBuilder(); + + var User = ds.define('User', {}); + assert.deepEqual(User.definition.properties.id, + {type: Number, id: 1, generated: true}); + + done(); + }); + + it('disables idInjection if the value is false', function (done) { + var ds = new ModelBuilder(); + + var User1 = ds.define('User', {}, {idInjection: false}); + assert(!User1.definition.properties.id); + done(); + }); + + it('updates generated id type by the connector', function (done) { + var builder = new ModelBuilder(); + + var User = builder.define('User', {id: {type: String, generated: true, id: true}}); + assert.deepEqual(User.definition.properties.id, + {type: String, id: 1, generated: true}); + + var ds = new DataSource('memory');// define models + User.attachTo(ds); + + assert.deepEqual(User.definition.properties.id, + {type: Number, id: 1, generated: true}); + + done(); + }); + }); describe('Load models with base', function () { @@ -445,6 +549,42 @@ describe('Load models with base', function () { }); }); +describe('DataSource connector types', function() { + it('should return an array of types', function() { + var ds = new DataSource('memory'); + var types = ds.getTypes(); + assert.deepEqual(types, ['db', 'nosql', 'memory']); + }); + + it('should test supported types by string', function() { + var ds = new DataSource('memory'); + var result = ds.supportTypes('db'); + assert(result); + }); + + it('should test supported types by array', function() { + var ds = new DataSource('memory'); + var result = ds.supportTypes(['db', 'memory']); + assert(result); + }); + + it('should test unsupported types by string', function() { + var ds = new DataSource('memory'); + var result = ds.supportTypes('rdbms'); + assert(!result); + }); + + it('should test unsupported types by array', function() { + var ds = new DataSource('memory'); + var result = ds.supportTypes(['rdbms', 'memory']); + assert(!result); + + result = ds.supportTypes(['rdbms']); + assert(!result); + }); + +}); + describe('DataSource constructor', function () { // Mocked require var loader = function (name) { diff --git a/test/memory.test.js b/test/memory.test.js new file mode 100644 index 00000000..4c613270 --- /dev/null +++ b/test/memory.test.js @@ -0,0 +1,95 @@ +var jdb = require('../'); +var DataSource = jdb.DataSource; +var path = require('path'); +var fs = require('fs'); +var assert = require('assert'); +var async = require('async'); + +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(); + } + }); + }); + + it('should save to a json file', function (done) { + var ds = new DataSource({ + connector: 'memory', + file: file + }); + + var User = ds.createModel('User', { + name: String, + bio: String, + approved: Boolean, + joinedAt: Date, + age: Number + }); + + var count = 0; + var ids = []; + 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); + }); + }); + }, function (err, results) { + // Now try to delete one + User.deleteById(ids[0], function (err) { + readModels(function (err, json) { + assert.equal(Object.keys(json.models.User).length, 2); + User.upsert({id: ids[1], name: 'John'}, function(err, result) { + readModels(function (err, json) { + assert.equal(Object.keys(json.models.User).length, 2); + var user = JSON.parse(json.models.User[ids[1]]); + assert.equal(user.name, 'John'); + done(); + }); + }); + }); + }); + }); + + }); + + // The saved memory.json from previous test should be loaded + it('should load from the json file', function (done) { + var ds = new DataSource({ + connector: 'memory', + file: file + }); + + var User = ds.createModel('User', { + name: String, + bio: String, + approved: Boolean, + joinedAt: Date, + age: Number + }); + + User.find(function (err, users) { + // There should be 2 records + assert.equal(users.length, 2); + done(err); + }); + + }); +}); +