diff --git a/docs/changelog.md b/docs/changelog.md index 7c854496..ce23f838 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -14,7 +14,9 @@ jugglingdb-changelog(3) - The History of JugglingDB **NOTE**: this change could break some code. * **Datatypes**: - Now object casts type of member on assignment. + Now object casts type of member on assignment. It may cause issues if + mongodb's ObjectID was manually used as type for property. Solution: not use + it as type directly, and specify wrapper instead. ### 0.2.1 diff --git a/index.js b/index.js index 50a64117..3a0dcbbc 100644 --- a/index.js +++ b/index.js @@ -2,8 +2,7 @@ var fs = require('fs'); var path = require('path'); exports.Schema = require('./lib/schema').Schema; -exports.AbstractClass = require('./lib/model.js').AbstractClass; -exports.Validatable = require('./lib/validations.js').Validatable; +exports.AbstractClass = require('./lib/model.js'); var baseSQL = './lib/sql'; diff --git a/lib/hooks.js b/lib/hooks.js index 11ed5a4a..80994f4c 100644 --- a/lib/hooks.js +++ b/lib/hooks.js @@ -1,9 +1,16 @@ +/** + * Module exports + */ exports.Hookable = Hookable; -function Hookable() { - // hookable class -}; +/** + * Hooks mixins for ./model.js + */ +var Hookable = require('./model.js'); +/** + * List of hooks available + */ Hookable.afterInitialize = null; Hookable.beforeValidate = null; Hookable.afterValidate = null; diff --git a/lib/include.js b/lib/include.js new file mode 100644 index 00000000..b908d575 --- /dev/null +++ b/lib/include.js @@ -0,0 +1,157 @@ +/** + * Include mixin for ./model.js + */ +var AbstractClass = require('./model.js'); + +/** + * Allows you to load relations of several objects and optimize numbers of requests. + * + * @param {Array} objects - array of instances + * @param {String}, {Object} or {Array} include - which relations you want to load. + * @param {Function} cb - Callback called when relations are loaded + * + * Examples: + * + * - User.include(users, 'posts', function() {}); will load all users posts with only one additional request. + * - User.include(users, ['posts'], function() {}); // same + * - User.include(users, ['posts', 'passports'], function() {}); // will load all users posts and passports with two + * additional requests. + * - Passport.include(passports, {owner: 'posts'}, function() {}); // will load all passports owner (users), and all + * posts of each owner loaded + * - Passport.include(passports, {owner: ['posts', 'passports']}); // ... + * - Passport.include(passports, {owner: [{posts: 'images'}, 'passports']}); // ... + * + */ +AbstractClass.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) + ) { + cb(null, objects); + return; + } + + include = processIncludeJoin(include); + + var keyVals = {}; + var objsByKeys = {}; + + var nbCallbacks = 0; + for (var i = 0; i < include.length; i++) { + var callback = processIncludeItem(objects, include[i], keyVals, objsByKeys); + if (callback !== null) { + nbCallbacks++; + callback(function() { + nbCallbacks--; + if (nbCallbacks == 0) { + cb(null, objects); + } + }); + } else { + cb(null, objects); + } + } + + function processIncludeJoin(ij) { + if (typeof ij === 'string') { + ij = [ij]; + } + if (ij.constructor.name === 'Object') { + var newIj = []; + for (var key in ij) { + var obj = {}; + obj[key] = ij[key]; + newIj.push(obj); + } + return newIj; + } + return ij; + } + + 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]; + } else { + var relationName = include; + var subInclude = []; + } + var relation = relations[relationName]; + + if (!relation) { + return function() { + cb(new Error('Relation "' + relationName + '" is not defined for ' + self.modelName + ' model')); + } + } + + var req = {'where': {}}; + + if (!keyVals[relation.keyFrom]) { + objsByKeys[relation.keyFrom] = {}; + for (var j = 0; j < objs.length; j++) { + if (!objsByKeys[relation.keyFrom][objs[j][relation.keyFrom]]) { + objsByKeys[relation.keyFrom][objs[j][relation.keyFrom]] = []; + } + objsByKeys[relation.keyFrom][objs[j][relation.keyFrom]].push(objs[j]); + } + keyVals[relation.keyFrom] = Object.keys(objsByKeys[relation.keyFrom]); + } + + if (keyVals[relation.keyFrom].length > 0) { + // deep clone is necessary since inq seems to change the processed array + var keysToBeProcessed = {}; + var inValues = []; + for (var j = 0; j < keyVals[relation.keyFrom].length; j++) { + keysToBeProcessed[keyVals[relation.keyFrom][j]] = true; + if (keyVals[relation.keyFrom][j] !== 'null' && keyVals[relation.keyFrom][j] !== 'undefined') { + inValues.push(keyVals[relation.keyFrom][j]); + } + } + + req['where'][relation.keyTo] = {inq: inValues}; + req['include'] = subInclude; + + return function(cb) { + relation.modelTo.all(req, function(err, objsIncluded) { + 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 = {}; + } + if (relation.multiple) { + if (!objectsFrom[j].__cachedRelations[relationName]) { + objectsFrom[j].__cachedRelations[relationName] = []; + } + objectsFrom[j].__cachedRelations[relationName].push(objsIncluded[i]); + } else { + objectsFrom[j].__cachedRelations[relationName] = objsIncluded[i]; + } + } + } + + // 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[j].__cachedRelations[relationName] = relation.multiple ? [] : null; + } + } + cb(err, objsIncluded); + }); + }; + } + + + return null; + } +} + diff --git a/lib/model.js b/lib/model.js index 916b0246..de726578 100644 --- a/lib/model.js +++ b/lib/model.js @@ -1,25 +1,23 @@ +/** + * Module exports class Model + */ +module.exports = AbstractClass; + /** * Module dependencies */ var util = require('util'); -var i8n = require('inflection'); -var jutil = require('./jutil.js'); var validations = require('./validations.js'); -var Validatable = validations.Validatable; var ValidationError = validations.ValidationError; var List = require('./list.js'); -var Hookable = require('./hooks.js').Hookable; -var DEFAULT_CACHE_LIMIT = 1000; +require('./hooks.js'); +require('./relations.js'); +require('./include.js'); + var BASE_TYPES = ['String', 'Boolean', 'Number', 'Date', 'Text']; -exports.AbstractClass = AbstractClass; - -AbstractClass.__proto__ = Validatable; -AbstractClass.prototype.__proto__ = Validatable.prototype; -jutil.inherits(AbstractClass, Hookable); - /** - * Abstract class - base class for all persist objects + * Model class - base class for all persist objects * provides **common API** to access any database adapter. * This class describes only abstract behavior layer, refer to `lib/adapters/*.js` * to learn more about specific adapter implementations @@ -462,158 +460,6 @@ AbstractClass.count = function (where, cb) { this.schema.adapter.count(this.modelName, cb, where); }; -/** - * Allows you to load relations of several objects and optimize numbers of requests. - * - * @param {Array} objects - array of instances - * @param {String}, {Object} or {Array} include - which relations you want to load. - * @param {Function} cb - Callback called when relations are loaded - * - * Examples: - * - * - User.include(users, 'posts', function() {}); will load all users posts with only one additional request. - * - User.include(users, ['posts'], function() {}); // same - * - User.include(users, ['posts', 'passports'], function() {}); // will load all users posts and passports with two - * additional requests. - * - Passport.include(passports, {owner: 'posts'}, function() {}); // will load all passports owner (users), and all - * posts of each owner loaded - * - Passport.include(passports, {owner: ['posts', 'passports']}); // ... - * - Passport.include(passports, {owner: [{posts: 'images'}, 'passports']}); // ... - * - */ -AbstractClass.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) - ) { - cb(null, objects); - return; - } - - include = processIncludeJoin(include); - - var keyVals = {}; - var objsByKeys = {}; - - var nbCallbacks = 0; - for (var i = 0; i < include.length; i++) { - var callback = processIncludeItem(objects, include[i], keyVals, objsByKeys); - if (callback !== null) { - nbCallbacks++; - callback(function() { - nbCallbacks--; - if (nbCallbacks == 0) { - cb(null, objects); - } - }); - } else { - cb(null, objects); - } - } - - function processIncludeJoin(ij) { - if (typeof ij === 'string') { - ij = [ij]; - } - if (ij.constructor.name === 'Object') { - var newIj = []; - for (var key in ij) { - var obj = {}; - obj[key] = ij[key]; - newIj.push(obj); - } - return newIj; - } - return ij; - } - - 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]; - } else { - var relationName = include; - var subInclude = []; - } - var relation = relations[relationName]; - - if (!relation) { - return function() { - cb(new Error('Relation "' + relationName + '" is not defined for ' + self.modelName + ' model')); - } - } - - var req = {'where': {}}; - - if (!keyVals[relation.keyFrom]) { - objsByKeys[relation.keyFrom] = {}; - for (var j = 0; j < objs.length; j++) { - if (!objsByKeys[relation.keyFrom][objs[j][relation.keyFrom]]) { - objsByKeys[relation.keyFrom][objs[j][relation.keyFrom]] = []; - } - objsByKeys[relation.keyFrom][objs[j][relation.keyFrom]].push(objs[j]); - } - keyVals[relation.keyFrom] = Object.keys(objsByKeys[relation.keyFrom]); - } - - if (keyVals[relation.keyFrom].length > 0) { - // deep clone is necessary since inq seems to change the processed array - var keysToBeProcessed = {}; - var inValues = []; - for (var j = 0; j < keyVals[relation.keyFrom].length; j++) { - keysToBeProcessed[keyVals[relation.keyFrom][j]] = true; - if (keyVals[relation.keyFrom][j] !== 'null' && keyVals[relation.keyFrom][j] !== 'undefined') { - inValues.push(keyVals[relation.keyFrom][j]); - } - } - - req['where'][relation.keyTo] = {inq: inValues}; - req['include'] = subInclude; - - return function(cb) { - relation.modelTo.all(req, function(err, objsIncluded) { - 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 = {}; - } - if (relation.multiple) { - if (!objectsFrom[j].__cachedRelations[relationName]) { - objectsFrom[j].__cachedRelations[relationName] = []; - } - objectsFrom[j].__cachedRelations[relationName].push(objsIncluded[i]); - } else { - objectsFrom[j].__cachedRelations[relationName] = objsIncluded[i]; - } - } - } - - // 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[j].__cachedRelations[relationName] = relation.multiple ? [] : null; - } - } - cb(err, objsIncluded); - }); - }; - } - - - return null; - } -} - /** * Return string representation of class * @@ -893,320 +739,6 @@ AbstractClass.prototype.reset = function () { }); }; -/** - * Declare hasMany relation - * - * @param {Class} anotherClass - class to has many - * @param {Object} params - configuration {as:, foreignKey:} - * @example `User.hasMany(Post, {as: 'posts', foreignKey: 'authorId'});` - */ -AbstractClass.hasMany = function hasMany(anotherClass, params) { - var thisClass = this, thisClassName = this.modelName; - params = params || {}; - if (typeof anotherClass === 'string') { - params.as = anotherClass; - if (params.model) { - anotherClass = params.model; - } else { - var anotherClassName = i8n.singularize(anotherClass).toLowerCase(); - for(var name in this.schema.models) { - if (name.toLowerCase() === anotherClassName) { - anotherClass = this.schema.models[name]; - } - } - } - } - var methodName = params.as || - i8n.camelize(i8n.pluralize(anotherClass.modelName), true); - var fk = params.foreignKey || i8n.camelize(thisClassName + '_id', true); - - this.relations[params['as']] = { - type: 'hasMany', - keyFrom: 'id', - keyTo: fk, - modelTo: anotherClass, - multiple: true - }; - // each instance of this class should have method named - // pluralize(anotherClass.modelName) - // which is actually just anotherClass.all({where: {thisModelNameId: this.id}}, cb); - defineScope(this.prototype, anotherClass, methodName, function () { - var x = {}; - x[fk] = this.id; - return {where: x}; - }, { - find: find, - destroy: destroy - }); - - // obviously, anotherClass should have attribute called `fk` - anotherClass.schema.defineForeignKey(anotherClass.modelName, fk); - - function find(id, cb) { - anotherClass.find(id, function (err, inst) { - if (err) return cb(err); - if (!inst) return cb(new Error('Not found')); - if (inst[fk] && inst[fk].toString() == this.id.toString()) { - cb(null, inst); - } else { - cb(new Error('Permission denied')); - } - }.bind(this)); - } - - function destroy(id, cb) { - this.find(id, function (err, inst) { - if (err) return cb(err); - if (inst) { - inst.destroy(cb); - } else { - cb(new Error('Not found')); - } - }); - } - -}; - -/** - * Declare belongsTo relation - * - * @param {Class} anotherClass - class to belong - * @param {Object} params - configuration {as: 'propertyName', foreignKey: 'keyName'} - * - * **Usage examples** - * Suppose model Post have a *belongsTo* relationship with User (the author of the post). You could declare it this way: - * Post.belongsTo(User, {as: 'author', foreignKey: 'userId'}); - * - * When a post is loaded, you can load the related author with: - * post.author(function(err, user) { - * // the user variable is your user object - * }); - * - * The related object is cached, so if later you try to get again the author, no additional request will be made. - * But there is an optional boolean parameter in first position that set whether or not you want to reload the cache: - * post.author(true, function(err, user) { - * // The user is reloaded, even if it was already cached. - * }); - * - * This optional parameter default value is false, so the related object will be loaded from cache if available. - */ -AbstractClass.belongsTo = function (anotherClass, params) { - params = params || {}; - if ('string' === typeof anotherClass) { - params.as = anotherClass; - if (params.model) { - anotherClass = params.model; - } else { - var anotherClassName = anotherClass.toLowerCase(); - for(var name in this.schema.models) { - if (name.toLowerCase() === anotherClassName) { - anotherClass = this.schema.models[name]; - } - } - } - } - var methodName = params.as || i8n.camelize(anotherClass.modelName, true); - var fk = params.foreignKey || methodName + 'Id'; - - this.relations[params['as']] = { - type: 'belongsTo', - keyFrom: fk, - keyTo: 'id', - modelTo: anotherClass, - multiple: false - }; - - this.schema.defineForeignKey(this.modelName, fk); - this.prototype['__finders__'] = this.prototype['__finders__'] || {}; - - this.prototype['__finders__'][methodName] = function (id, cb) { - if (id === null) { - cb(null, null); - return; - } - anotherClass.find(id, function (err,inst) { - if (err) return cb(err); - if (!inst) return cb(null, null); - if (inst.id === this[fk]) { - cb(null, inst); - } else { - cb(new Error('Permission denied')); - } - }.bind(this)); - }; - - this.prototype[methodName] = function (refresh, p) { - if (arguments.length === 1) { - p = refresh; - refresh = false; - } else if (arguments.length > 2) { - throw new Error('Method can\'t be called with more than two arguments'); - } - var self = this; - var cachedValue; - if (!refresh && this.__cachedRelations && (typeof this.__cachedRelations[methodName] !== 'undefined')) { - cachedValue = this.__cachedRelations[methodName]; - } - if (p instanceof AbstractClass) { // acts as setter - this[fk] = p.id; - this.__cachedRelations[methodName] = p; - } else if (typeof p === 'function') { // acts as async getter - if (typeof cachedValue === 'undefined') { - this.__finders__[methodName].apply(self, [this[fk], function(err, inst) { - if (!err) { - self.__cachedRelations[methodName] = inst; - } - p(err, inst); - }]); - return this[fk]; - } else { - p(null, cachedValue); - return cachedValue; - } - } else if (typeof p === 'undefined') { // acts as sync getter - return this[fk]; - } else { // setter - this[fk] = p; - delete this.__cachedRelations[methodName]; - } - }; - -}; - -/** - * Define scope - * TODO: describe behavior and usage examples - */ -AbstractClass.scope = function (name, params) { - defineScope(this, this, name, params); -}; - -function defineScope(cls, targetClass, name, params, methods) { - - // collect meta info about scope - if (!cls._scopeMeta) { - cls._scopeMeta = {}; - } - - // only makes sence to add scope in meta if base and target classes - // are same - if (cls === targetClass) { - cls._scopeMeta[name] = params; - } else { - if (!targetClass._scopeMeta) { - targetClass._scopeMeta = {}; - } - } - - Object.defineProperty(cls, name, { - enumerable: false, - configurable: true, - get: function () { - var f = function caller(condOrRefresh, cb) { - var actualCond = {}; - var actualRefresh = false; - var saveOnCache = true; - if (arguments.length === 1) { - cb = condOrRefresh; - } else if (arguments.length === 2) { - if (typeof condOrRefresh === 'boolean') { - actualRefresh = condOrRefresh; - } else { - actualCond = condOrRefresh; - actualRefresh = true; - saveOnCache = false; - } - } else { - throw new Error('Method can be only called with one or two arguments'); - } - - if (!this.__cachedRelations || (typeof this.__cachedRelations[name] == 'undefined') || actualRefresh) { - var self = this; - return targetClass.all(mergeParams(actualCond, caller._scope), function(err, data) { - if (!err && saveOnCache) { - self.__cachedRelations[name] = data; - } - cb(err, data); - }); - } else { - cb(null, this.__cachedRelations[name]); - } - }; - f._scope = typeof params === 'function' ? params.call(this) : params; - f.build = build; - f.create = create; - f.destroyAll = destroyAll; - for (var i in methods) { - f[i] = methods[i].bind(this); - } - - // define sub-scopes - Object.keys(targetClass._scopeMeta).forEach(function (name) { - Object.defineProperty(f, name, { - enumerable: false, - get: function () { - mergeParams(f._scope, targetClass._scopeMeta[name]); - return f; - } - }); - }.bind(this)); - return f; - } - }); - - // and it should have create/build methods with binded thisModelNameId param - function build(data) { - return new targetClass(mergeParams(this._scope, {where:data || {}}).where); - } - - function create(data, cb) { - if (typeof data === 'function') { - cb = data; - data = {}; - } - this.build(data).save(cb); - } - - /* - Callback - - The callback will be called after all elements are destroyed - - For every destroy call which results in an error - - If fetching the Elements on which destroyAll is called results in an error - */ - function destroyAll(cb) { - targetClass.all(this._scope, function (err, data) { - if (err) { - cb(err); - } else { - (function loopOfDestruction (data) { - if(data.length > 0) { - data.shift().destroy(function(err) { - if(err && cb) cb(err); - loopOfDestruction(data); - }); - } else { - if(cb) cb(); - } - }(data)); - } - }); - } - - function mergeParams(base, update) { - if (update.where) { - base.where = merge(base.where, update.where); - } - - // overwrite order - if (update.order) { - base.order = update.order; - } - - return base; - - } -} - AbstractClass.prototype.inspect = function () { return util.inspect(this.__data, false, 4, true); }; @@ -1222,22 +754,6 @@ function isdef(s) { return s !== undef; } -/** - * Merge `base` and `update` params - * @param {Object} base - base object (updating this object) - * @param {Object} update - object with new data to update base - * @returns {Object} `base` - */ -function merge(base, update) { - base = base || {}; - if (update) { - Object.keys(update).forEach(function (key) { - base[key] = update[key]; - }); - } - return base; -} - /** * Define readonly property on object * @@ -1253,4 +769,3 @@ function defineReadonlyProp(obj, key, value) { value: value }); } - diff --git a/lib/relations.js b/lib/relations.js new file mode 100644 index 00000000..8a4e8114 --- /dev/null +++ b/lib/relations.js @@ -0,0 +1,191 @@ +/** + * Dependencies + */ +var i8n = require('inflection'); +var defineScope = require('./scope.js').defineScope; + +/** + * Relations mixins for ./model.js + */ +var AbstractClass = require('./model.js'); + +/** + * Declare hasMany relation + * + * @param {Class} anotherClass - class to has many + * @param {Object} params - configuration {as:, foreignKey:} + * @example `User.hasMany(Post, {as: 'posts', foreignKey: 'authorId'});` + */ +AbstractClass.hasMany = function hasMany(anotherClass, params) { + var thisClass = this, thisClassName = this.modelName; + params = params || {}; + if (typeof anotherClass === 'string') { + params.as = anotherClass; + if (params.model) { + anotherClass = params.model; + } else { + var anotherClassName = i8n.singularize(anotherClass).toLowerCase(); + for(var name in this.schema.models) { + if (name.toLowerCase() === anotherClassName) { + anotherClass = this.schema.models[name]; + } + } + } + } + var methodName = params.as || + i8n.camelize(i8n.pluralize(anotherClass.modelName), true); + var fk = params.foreignKey || i8n.camelize(thisClassName + '_id', true); + + this.relations[params['as']] = { + type: 'hasMany', + keyFrom: 'id', + keyTo: fk, + modelTo: anotherClass, + multiple: true + }; + // each instance of this class should have method named + // pluralize(anotherClass.modelName) + // which is actually just anotherClass.all({where: {thisModelNameId: this.id}}, cb); + defineScope(this.prototype, anotherClass, methodName, function () { + var x = {}; + x[fk] = this.id; + return {where: x}; + }, { + find: find, + destroy: destroy + }); + + // obviously, anotherClass should have attribute called `fk` + anotherClass.schema.defineForeignKey(anotherClass.modelName, fk); + + function find(id, cb) { + anotherClass.find(id, function (err, inst) { + if (err) return cb(err); + if (!inst) return cb(new Error('Not found')); + if (inst[fk] && inst[fk].toString() == this.id.toString()) { + cb(null, inst); + } else { + cb(new Error('Permission denied')); + } + }.bind(this)); + } + + function destroy(id, cb) { + this.find(id, function (err, inst) { + if (err) return cb(err); + if (inst) { + inst.destroy(cb); + } else { + cb(new Error('Not found')); + } + }); + } + +}; + +/** + * Declare belongsTo relation + * + * @param {Class} anotherClass - class to belong + * @param {Object} params - configuration {as: 'propertyName', foreignKey: 'keyName'} + * + * **Usage examples** + * Suppose model Post have a *belongsTo* relationship with User (the author of the post). You could declare it this way: + * Post.belongsTo(User, {as: 'author', foreignKey: 'userId'}); + * + * When a post is loaded, you can load the related author with: + * post.author(function(err, user) { + * // the user variable is your user object + * }); + * + * The related object is cached, so if later you try to get again the author, no additional request will be made. + * But there is an optional boolean parameter in first position that set whether or not you want to reload the cache: + * post.author(true, function(err, user) { + * // The user is reloaded, even if it was already cached. + * }); + * + * This optional parameter default value is false, so the related object will be loaded from cache if available. + */ +AbstractClass.belongsTo = function (anotherClass, params) { + params = params || {}; + if ('string' === typeof anotherClass) { + params.as = anotherClass; + if (params.model) { + anotherClass = params.model; + } else { + var anotherClassName = anotherClass.toLowerCase(); + for(var name in this.schema.models) { + if (name.toLowerCase() === anotherClassName) { + anotherClass = this.schema.models[name]; + } + } + } + } + var methodName = params.as || i8n.camelize(anotherClass.modelName, true); + var fk = params.foreignKey || methodName + 'Id'; + + this.relations[params['as']] = { + type: 'belongsTo', + keyFrom: fk, + keyTo: 'id', + modelTo: anotherClass, + multiple: false + }; + + this.schema.defineForeignKey(this.modelName, fk); + this.prototype['__finders__'] = this.prototype['__finders__'] || {}; + + this.prototype['__finders__'][methodName] = function (id, cb) { + if (id === null) { + cb(null, null); + return; + } + anotherClass.find(id, function (err,inst) { + if (err) return cb(err); + if (!inst) return cb(null, null); + if (inst.id === this[fk]) { + cb(null, inst); + } else { + cb(new Error('Permission denied')); + } + }.bind(this)); + }; + + this.prototype[methodName] = function (refresh, p) { + if (arguments.length === 1) { + p = refresh; + refresh = false; + } else if (arguments.length > 2) { + throw new Error('Method can\'t be called with more than two arguments'); + } + var self = this; + var cachedValue; + if (!refresh && this.__cachedRelations && (typeof this.__cachedRelations[methodName] !== 'undefined')) { + cachedValue = this.__cachedRelations[methodName]; + } + if (p instanceof AbstractClass) { // acts as setter + this[fk] = p.id; + this.__cachedRelations[methodName] = p; + } else if (typeof p === 'function') { // acts as async getter + if (typeof cachedValue === 'undefined') { + this.__finders__[methodName].apply(self, [this[fk], function(err, inst) { + if (!err) { + self.__cachedRelations[methodName] = inst; + } + p(err, inst); + }]); + return this[fk]; + } else { + p(null, cachedValue); + return cachedValue; + } + } else if (typeof p === 'undefined') { // acts as sync getter + return this[fk]; + } else { // setter + this[fk] = p; + delete this.__cachedRelations[methodName]; + } + }; + +}; + diff --git a/lib/schema.js b/lib/schema.js index f1d5bd6a..0ea107d8 100644 --- a/lib/schema.js +++ b/lib/schema.js @@ -1,7 +1,7 @@ /** * Module dependencies */ -var AbstractClass = require('./model.js').AbstractClass; +var AbstractClass = require('./model.js'); var List = require('./list.js'); var EventEmitter = require('events').EventEmitter; var util = require('util'); diff --git a/lib/scope.js b/lib/scope.js new file mode 100644 index 00000000..8b90432c --- /dev/null +++ b/lib/scope.js @@ -0,0 +1,160 @@ +/** + * Module exports + */ +exports.defineScope = defineScope; + +/** + * Scope mixin for ./model.js + */ +var Model = require('./model.js'); + +/** + * Define scope + * TODO: describe behavior and usage examples + */ +Model.scope = function (name, params) { + defineScope(this, this, name, params); +}; + +function defineScope(cls, targetClass, name, params, methods) { + + // collect meta info about scope + if (!cls._scopeMeta) { + cls._scopeMeta = {}; + } + + // only makes sence to add scope in meta if base and target classes + // are same + if (cls === targetClass) { + cls._scopeMeta[name] = params; + } else { + if (!targetClass._scopeMeta) { + targetClass._scopeMeta = {}; + } + } + + Object.defineProperty(cls, name, { + enumerable: false, + configurable: true, + get: function () { + var f = function caller(condOrRefresh, cb) { + var actualCond = {}; + var actualRefresh = false; + var saveOnCache = true; + if (arguments.length === 1) { + cb = condOrRefresh; + } else if (arguments.length === 2) { + if (typeof condOrRefresh === 'boolean') { + actualRefresh = condOrRefresh; + } else { + actualCond = condOrRefresh; + actualRefresh = true; + saveOnCache = false; + } + } else { + throw new Error('Method can be only called with one or two arguments'); + } + + if (!this.__cachedRelations || (typeof this.__cachedRelations[name] == 'undefined') || actualRefresh) { + var self = this; + return targetClass.all(mergeParams(actualCond, caller._scope), function(err, data) { + if (!err && saveOnCache) { + self.__cachedRelations[name] = data; + } + cb(err, data); + }); + } else { + cb(null, this.__cachedRelations[name]); + } + }; + f._scope = typeof params === 'function' ? params.call(this) : params; + f.build = build; + f.create = create; + f.destroyAll = destroyAll; + for (var i in methods) { + f[i] = methods[i].bind(this); + } + + // define sub-scopes + Object.keys(targetClass._scopeMeta).forEach(function (name) { + Object.defineProperty(f, name, { + enumerable: false, + get: function () { + mergeParams(f._scope, targetClass._scopeMeta[name]); + return f; + } + }); + }.bind(this)); + return f; + } + }); + + // and it should have create/build methods with binded thisModelNameId param + function build(data) { + return new targetClass(mergeParams(this._scope, {where:data || {}}).where); + } + + function create(data, cb) { + if (typeof data === 'function') { + cb = data; + data = {}; + } + this.build(data).save(cb); + } + + /* + Callback + - The callback will be called after all elements are destroyed + - For every destroy call which results in an error + - If fetching the Elements on which destroyAll is called results in an error + */ + function destroyAll(cb) { + targetClass.all(this._scope, function (err, data) { + if (err) { + cb(err); + } else { + (function loopOfDestruction (data) { + if(data.length > 0) { + data.shift().destroy(function(err) { + if(err && cb) cb(err); + loopOfDestruction(data); + }); + } else { + if(cb) cb(); + } + }(data)); + } + }); + } + + function mergeParams(base, update) { + if (update.where) { + base.where = merge(base.where, update.where); + } + + // overwrite order + if (update.order) { + base.order = update.order; + } + + return base; + + } +} + +/** + * Merge `base` and `update` params + * @param {Object} base - base object (updating this object) + * @param {Object} update - object with new data to update base + * @returns {Object} `base` + */ +function merge(base, update) { + base = base || {}; + if (update) { + Object.keys(update).forEach(function (key) { + base[key] = update[key]; + }); + } + return base; +} + diff --git a/lib/validations.js b/lib/validations.js index 153daf01..9d59434f 100644 --- a/lib/validations.js +++ b/lib/validations.js @@ -1,8 +1,10 @@ -exports.Validatable = Validatable; +/** + * Module exports + */ exports.ValidationError = ValidationError; /** - * Validation encapsulated in this abstract class. + * Validation mixins for model.js * * Basically validation configurators is just class methods, which adds validations * configs to AbstractClass._validations. Each of this validations run when @@ -16,9 +18,7 @@ exports.ValidationError = ValidationError; * In more complicated cases it can be {Hash} of messages (for each case): * `User.validatesLengthOf('password', { min: 6, max: 20, message: {min: 'too short', max: 'too long'}});` */ -function Validatable() { - // validatable class -}; +var Validatable = require('./model.js'); /** * Validate presence. This validation fails when validated field is blank. diff --git a/test/basic-querying.test.js b/test/basic-querying.test.js index 82eabc2a..af3665b4 100644 --- a/test/basic-querying.test.js +++ b/test/basic-querying.test.js @@ -174,8 +174,7 @@ describe('basic-querying', function() { }); }); - // TODO: it's not basic query, move to advanced querying test - it.skip('should find last record in filtered set', function(done) { + it('should find last record in filtered set', function(done) { User.findOne({ where: {role: 'lead'}, order: 'order DESC' @@ -188,7 +187,7 @@ describe('basic-querying', function() { }); }); - it.skip('should work even when find by id', function(done) { + it('should work even when find by id', function(done) { User.findOne(function(e, u) { User.findOne({where: {id: u.id}}, function(err, user) { should.not.exist(err); diff --git a/test/relations.test.js b/test/relations.test.js index f97cc3d4..d63df002 100644 --- a/test/relations.test.js +++ b/test/relations.test.js @@ -159,7 +159,13 @@ describe('relations', function() { }); }); - describe('hasAndBelongsToMany', function() { - it('can be declared'); + describe.skip('hasAndBelongsToMany', function() { + var Article, Tag; + it('can be declared', function(done) { + Article = db.define('Article', {title: String}); + Tag = db.define('Tag', {name: String}); + Article.hasAndBelongsToMany('tags'); + }); }); + });