diff --git a/README.md b/README.md index 1a14aa30..66f41ee5 100644 --- a/README.md +++ b/README.md @@ -5,8 +5,8 @@ for interacting with databases, REST APIs, and other data sources. It was initially forked from [JugglingDB](https://github.com/1602/jugglingdb). **For full documentation, see the official StrongLoop documentation**: - * [Data sources and connectors](http://docs.strongloop.com/display/DOC/Data+sources+and+connectors) - * [Data Source Juggler](http://docs.strongloop.com/display/DOC/Data+Source+Juggler). + * [Data sources and connectors](http://docs.strongloop.com/display/LB/Data+sources+and+connectors) + * [Creating data sources and connected models](http://docs.strongloop.com/display/LB/Creating+data+sources+and+connected+models). ## Installation diff --git a/lib/connectors/memory.js b/lib/connectors/memory.js index 4b868601..829cca34 100644 --- a/lib/connectors/memory.js +++ b/lib/connectors/memory.js @@ -270,6 +270,12 @@ Memory.prototype.all = function all(model, filter, callback) { }.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; diff --git a/lib/dao.js b/lib/dao.js index f16cbeeb..a1508eb1 100644 --- a/lib/dao.js +++ b/lib/dao.js @@ -180,8 +180,8 @@ DataAccessObject.create = function (data, callback) { }); }); }, obj); - }, obj); - }, obj); + }, obj, callback); + }, obj, callback); } // for chaining @@ -201,7 +201,10 @@ function stillConnecting(dataSource, obj, args) { * @param {Object} data The model instance data * @param {Function} callback The callback function (optional). */ -DataAccessObject.upsert = DataAccessObject.updateOrCreate = function upsert(data, callback) { +// [FIXME] rfeng: This is a hack to set up 'upsert' first so that +// 'upsert' will be used as the name for strong-remoting to keep it backward +// compatible for angular SDK +DataAccessObject.updateOrCreate = DataAccessObject.upsert = function upsert(data, callback) { if (stillConnecting(this.getDataSource(), this, arguments)) { return; } @@ -386,6 +389,48 @@ DataAccessObject._normalize = function (filter) { filter.skip = offset; } + if (filter.order) { + var order = filter.order; + if (!Array.isArray(order)) { + order = [order]; + } + var fields = []; + for (var i = 0, m = order.length; i < m; i++) { + if (typeof order[i] === 'string') { + // Normalize 'f1 ASC, f2 DESC, f3' to ['f1 ASC', 'f2 DESC', 'f3'] + var tokens = order[i].split(/(?:\s*,\s*)+/); + for (var t = 0, n = tokens.length; t < n; t++) { + var token = tokens[t]; + if (token.length === 0) { + // Skip empty token + continue; + } + var parts = token.split(/\s+/); + if (parts.length >= 2) { + var dir = parts[1].toUpperCase(); + if (dir === 'ASC' || dir === 'DESC') { + token = parts[0] + ' ' + dir; + } else { + err = new Error(util.format('The order %j has invalid direction', token)); + err.statusCode = 400; + throw err; + } + } + fields.push(token); + } + } else { + err = new Error(util.format('The order %j is not valid', order[i])); + err.statusCode = 400; + throw err; + } + } + if (fields.length === 1 && typeof filter.order === 'string') { + filter.order = fields[0]; + } else { + filter.order = fields; + } + } + // normalize fields as array of included property names if (filter.fields) { filter.fields = fieldsToArray(filter.fields, @@ -397,6 +442,25 @@ DataAccessObject._normalize = function (filter) { return filter; }; +function DateType(arg) { + return new Date(arg); +} + +function BooleanType(val) { + if (val === 'true') { + return true; + } else if (val === 'false') { + return false; + } else { + return Boolean(val); + } +} + +function NumberType(val) { + var num = Number(val); + return !isNaN(num) ? num : val; +} + /* * Coerce values based the property types * @param {Object} where The where clause @@ -422,11 +486,11 @@ DataAccessObject._coerce = function (where) { if (p === 'and' || p === 'or' || p === 'nor') { var clauses = where[p]; if (Array.isArray(clauses)) { - for (var i = 0; i < clauses.length; i++) { - self._coerce(clauses[i]); + for (var k = 0; k < clauses.length; k++) { + self._coerce(clauses[k]); } } else { - err = new Error(util.format('The %p operator has invalid clauses %j', p, clauses)); + err = new Error(util.format('The %s operator has invalid clauses %j', p, clauses)); err.statusCode = 400; throw err; } @@ -440,30 +504,16 @@ DataAccessObject._coerce = function (where) { DataType = DataType[0]; } if (DataType === Date) { - var OrigDate = Date; - DataType = function Date(arg) { - return new OrigDate(arg); - }; + DataType = DateType; } else if (DataType === Boolean) { - DataType = function (val) { - if (val === 'true') { - return true; - } else if (val === 'false') { - return false; - } else { - return Boolean(val); - } - }; + DataType = BooleanType; } else if (DataType === Number) { // This fixes a regression in mongodb connector // For numbers, only convert it produces a valid number // LoopBack by default injects a number id. We should fix it based // on the connector's input, for example, MongoDB should use string // while RDBs typically use number - DataType = function (val) { - var num = Number(val); - return !isNaN(num) ? num : val; - }; + DataType = NumberType; } if (!DataType) { @@ -495,6 +545,31 @@ DataAccessObject._coerce = function (where) { if (op in val) { val = val[op]; operator = op; + switch(operator) { + case 'inq': + case 'nin': + if (!Array.isArray(val)) { + err = new Error(util.format('The %s property has invalid clause %j', p, where[p])); + err.statusCode = 400; + throw err; + } + break; + case 'between': + if (!Array.isArray(val) || val.length !== 2) { + err = new Error(util.format('The %s property has invalid clause %j', p, where[p])); + err.statusCode = 400; + throw err; + } + break; + case 'like': + case 'nlike': + if (!(typeof val === 'string' || val instanceof RegExp)) { + err = new Error(util.format('The %s property has invalid clause %j', p, where[p])); + err.statusCode = 400; + throw err; + } + break; + } break; } } @@ -758,7 +833,10 @@ DataAccessObject.remove = DataAccessObject.deleteAll = DataAccessObject.destroyA * @param {Function} cb Callback called with (err) */ -DataAccessObject.removeById = DataAccessObject.deleteById = DataAccessObject.destroyById = function deleteById(id, cb) { +// [FIXME] rfeng: This is a hack to set up 'deleteById' first so that +// 'deleteById' will be used as the name for strong-remoting to keep it backward +// compatible for angular SDK +DataAccessObject.removeById = DataAccessObject.destroyById = DataAccessObject.deleteById = function deleteById(id, cb) { if (stillConnecting(this.getDataSource(), this, arguments)) return; var Model = this; @@ -874,8 +952,8 @@ DataAccessObject.prototype.save = function (options, callback) { }); }); }); - }, data); - }, data); + }, data, callback); + }, data, callback); } }; @@ -966,7 +1044,7 @@ DataAccessObject.prototype.remove = Model.emit('deleted', id); }); }.bind(this)); - }); + }, null, cb); }; /** @@ -1028,7 +1106,8 @@ DataAccessObject.prototype.updateAttributes = function updateAttributes(data, cb typedData[key] = inst[key]; } - inst._adapter().updateAttributes(model, getIdValue(inst.constructor, inst), inst.constructor._forDB(typedData), function (err) { + inst._adapter().updateAttributes(model, getIdValue(inst.constructor, inst), + inst.constructor._forDB(typedData), function (err) { done.call(inst, function () { saveDone.call(inst, function () { if(cb) cb(err, inst); @@ -1036,8 +1115,8 @@ DataAccessObject.prototype.updateAttributes = function updateAttributes(data, cb }); }); }); - }, data); - }, data); + }, data, cb); + }, data, cb); } }, data); }; diff --git a/lib/datasource.js b/lib/datasource.js index 7c8b4190..8b5d51ab 100644 --- a/lib/datasource.js +++ b/lib/datasource.js @@ -221,10 +221,9 @@ DataSource._resolveConnector = function (name, loader) { var connector = tryModules(names, loader); var error = null; if (!connector) { - error = '\nWARNING: LoopBack connector "' + name - + '" is not installed at any of the locations ' + names - + '. To fix, run:\n\n npm install ' - + name + '\n'; + 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, @@ -364,7 +363,7 @@ function isModelClass(cls) { return cls.prototype instanceof ModelBaseClass; } -DataSource.relationTypes = ['belongsTo', 'hasMany', 'hasAndBelongsToMany']; +DataSource.relationTypes = ['belongsTo', 'hasMany', 'hasAndBelongsToMany', 'hasOne']; function isModelDataSourceAttached(model) { return model && (!model.settings.unresolved) && (model.dataSource instanceof DataSource); @@ -1579,22 +1578,26 @@ DataSource.prototype.idProperty = function (modelName) { * @param {String} foreignClassName The foreign model name */ DataSource.prototype.defineForeignKey = function defineForeignKey(className, key, foreignClassName) { - // quit if key already defined - if (this.getModelDefinition(className).rawProperties[key]) return; - - var defaultType = Number; - if (foreignClassName) { - var foreignModel = this.getModelDefinition(foreignClassName); - var pkName = foreignModel && foreignModel.idName(); - if (pkName) { - defaultType = foreignModel.properties[pkName].type; - } + var pkType = null; + var foreignModel = this.getModelDefinition(foreignClassName); + var 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; + } + if (this.connector.defineForeignKey) { var cb = function (err, keyType) { if (err) throw err; // Add the foreign key property to the data source _models - this.defineProperty(className, key, {type: keyType || defaultType}); + this.defineProperty(className, key, {type: keyType || pkType}); }.bind(this); switch (this.connector.defineForeignKey.length) { case 4: @@ -1607,7 +1610,7 @@ DataSource.prototype.defineForeignKey = function defineForeignKey(className, key } } else { // Add the foreign key property to the data source _models - this.defineProperty(className, key, {type: defaultType}); + this.defineProperty(className, key, {type: pkType}); } }; diff --git a/lib/hooks.js b/lib/hooks.js index 9288f55b..acf69e4a 100644 --- a/lib/hooks.js +++ b/lib/hooks.js @@ -27,7 +27,7 @@ Hookable.beforeDestroy = null; Hookable.afterDestroy = null; // TODO: Evaluate https://github.com/bnoguchi/hooks-js/ -Hookable.prototype.trigger = function trigger(actionName, work, data) { +Hookable.prototype.trigger = function trigger(actionName, work, data, callback) { var capitalizedName = capitalize(actionName); var beforeHook = this.constructor["before" + capitalizedName] || this.constructor["pre" + capitalizedName]; @@ -42,8 +42,13 @@ Hookable.prototype.trigger = function trigger(actionName, work, data) { // we only call "before" hook when we have actual action (work) to perform if (work) { if (beforeHook) { - // before hook should be called on instance with one param: callback + // before hook should be called on instance with two parameters: next and data beforeHook.call(inst, function () { + // Check arguments to next(err, result) + if (arguments.length) { + return callback && callback.apply(null, arguments); + } + // No err & result is present, proceed with the real work // actual action also have one param: callback work.call(inst, next); }, data); diff --git a/lib/model-builder.js b/lib/model-builder.js index ecbbe9a4..505d59cb 100644 --- a/lib/model-builder.js +++ b/lib/model-builder.js @@ -37,6 +37,7 @@ function ModelBuilder() { // create blank models pool this.models = {}; this.definitions = {}; + this.defaultModelBaseClass = DefaultModelBaseClass; } // Inherit from EventEmitter @@ -131,7 +132,7 @@ ModelBuilder.prototype.define = function defineClass(className, properties, sett } // Set up the base model class - var ModelBaseClass = parent || DefaultModelBaseClass; + var ModelBaseClass = parent || this.defaultModelBaseClass; var baseClass = settings.base || settings['super']; if (baseClass) { if (isModelClass(baseClass)) { diff --git a/lib/relation-definition.js b/lib/relation-definition.js index bf32e5a3..13d1914e 100644 --- a/lib/relation-definition.js +++ b/lib/relation-definition.js @@ -5,6 +5,7 @@ var assert = require('assert'); var util = require('util'); var i8n = require('inflection'); var defineScope = require('./scope.js').defineScope; +var mergeQuery = require('./scope.js').mergeQuery; var ModelBaseClass = require('./model.js'); exports.Relation = Relation; @@ -68,6 +69,8 @@ function RelationDefinition(definition) { this.modelThrough = definition.modelThrough; this.keyThrough = definition.keyThrough; this.multiple = (this.type !== 'belongsTo' && this.type !== 'hasOne'); + this.properties = definition.properties || {}; + this.scope = definition.scope; } RelationDefinition.prototype.toJSON = function () { @@ -87,6 +90,41 @@ RelationDefinition.prototype.toJSON = function () { return json; }; +/** + * Apply the configured scope to the filter/query object. + * @param {Object} modelInstance + * @param {Object} filter (where, order, limit, fields, ...) + */ +RelationDefinition.prototype.applyScope = function(modelInstance, filter) { + if (typeof this.scope === 'function') { + var scope = this.scope.call(this, modelInstance, filter); + if (typeof scope === 'object') { + mergeQuery(filter, scope); + } + } else if (typeof this.scope === 'object') { + mergeQuery(filter, this.scope); + } +}; + +/** + * Apply the configured properties to the target object. + * @param {Object} modelInstance + * @param {Object} target + */ +RelationDefinition.prototype.applyProperties = function(modelInstance, target) { + if (typeof this.properties === 'function') { + var data = this.properties.call(this, modelInstance); + for(var k in data) { + target[k] = data[k]; + } + } else if (typeof this.properties === 'object') { + for(var k in this.properties) { + var key = this.properties[k]; + target[key] = modelInstance[k]; + } + } +}; + /** * A relation attaching to a given model instance * @param {RelationDefinition|Object} definition @@ -315,7 +353,7 @@ RelationDefinition.hasMany = function hasMany(modelFrom, modelTo, params) { var fk = params.foreignKey || i8n.camelize(thisClassName + '_id', true); var idName = modelFrom.dataSource.idName(modelFrom.modelName) || 'id'; - + var definition = new RelationDefinition({ name: relationName, type: RelationTypes.hasMany, @@ -323,9 +361,11 @@ RelationDefinition.hasMany = function hasMany(modelFrom, modelTo, params) { keyFrom: idName, keyTo: fk, modelTo: modelTo, - multiple: true + multiple: true, + properties: params.properties, + scope: params.scope }); - + if (params.through) { definition.modelThrough = params.through; var keyThrough = definition.throughKey || i8n.camelize(modelTo.modelName + '_id', true); @@ -336,25 +376,104 @@ RelationDefinition.hasMany = function hasMany(modelFrom, modelTo, params) { if (!params.through) { // obviously, modelTo should have attribute called `fk` - modelTo.dataSource.defineForeignKey(modelTo.modelName, fk, this.modelName); + modelTo.dataSource.defineForeignKey(modelTo.modelName, fk, modelFrom.modelName); } var scopeMethods = { findById: scopeMethod(definition, 'findById'), - destroy: scopeMethod(definition, 'destroyById') - } + destroy: scopeMethod(definition, 'destroyById'), + updateById: scopeMethod(definition, 'updateById'), + exists: scopeMethod(definition, 'exists') + }; + + var findByIdFunc = scopeMethods.findById; + findByIdFunc.shared = true; + findByIdFunc.http = {verb: 'get', path: '/' + relationName + '/:fk'}; + findByIdFunc.accepts = {arg: 'fk', type: 'any', + description: 'Foreign key for ' + relationName, required: true, + http: {source: 'path'}}; + findByIdFunc.description = 'Find a related item by id for ' + relationName; + findByIdFunc.returns = {arg: 'result', type: 'object', root: true}; + + modelFrom.prototype['__findById__' + relationName] = findByIdFunc; + + var destroyByIdFunc = scopeMethods.destroy; + destroyByIdFunc.shared = true; + destroyByIdFunc.http = {verb: 'delete', path: '/' + relationName + '/:fk'}; + destroyByIdFunc.accepts = {arg: 'fk', type: 'any', + description: 'Foreign key for ' + relationName, required: true, + http: {source: 'path'}}; + destroyByIdFunc.description = 'Delete a related item by id for ' + relationName; + destroyByIdFunc.returns = {}; + + modelFrom.prototype['__destroyById__' + relationName] = destroyByIdFunc; + + var updateByIdFunc = scopeMethods.updateById; + updateByIdFunc.shared = true; + updateByIdFunc.http = {verb: 'put', path: '/' + relationName + '/:fk'}; + updateByIdFunc.accepts = {arg: 'fk', type: 'any', + description: 'Foreign key for ' + relationName, required: true, + http: {source: 'path'}}; + updateByIdFunc.description = 'Update a related item by id for ' + relationName; + updateByIdFunc.returns = {arg: 'result', type: 'object', root: true}; + + modelFrom.prototype['__updateById__' + relationName] = updateByIdFunc; if(definition.modelThrough) { scopeMethods.create = scopeMethod(definition, 'create'); scopeMethods.add = scopeMethod(definition, 'add'); scopeMethods.remove = scopeMethod(definition, 'remove'); - } + var addFunc = scopeMethods.add; + addFunc.shared = true; + addFunc.http = {verb: 'put', path: '/' + relationName + '/rel/:fk'}; + addFunc.accepts = {arg: 'fk', type: 'any', + description: 'Foreign key for ' + relationName, required: true, + http: {source: 'path'}}; + addFunc.description = 'Add a related item by id for ' + relationName; + addFunc.returns = {arg: relationName, type: 'object', root: true}; + + modelFrom.prototype['__link__' + relationName] = addFunc; + + var removeFunc = scopeMethods.remove; + removeFunc.shared = true; + removeFunc.http = {verb: 'delete', path: '/' + relationName + '/rel/:fk'}; + removeFunc.accepts = {arg: 'fk', type: 'any', + description: 'Foreign key for ' + relationName, required: true, + http: {source: 'path'}}; + removeFunc.description = 'Remove the ' + relationName + ' relation to an item by id'; + removeFunc.returns = {}; + + modelFrom.prototype['__unlink__' + relationName] = removeFunc; + + // FIXME: [rfeng] How to map a function with callback(err, true|false) to HEAD? + // true --> 200 and false --> 404? + /* + var existsFunc = scopeMethods.exists; + existsFunc.shared = true; + existsFunc.http = {verb: 'head', path: '/' + relationName + '/rel/:fk'}; + existsFunc.accepts = {arg: 'fk', type: 'any', + description: 'Foreign key for ' + relationName, required: true, + http: {source: 'path'}}; + existsFunc.description = 'Check the existence of ' + relationName + ' relation to an item by id'; + existsFunc.returns = {}; + + modelFrom.prototype['__exists__' + relationName] = existsFunc; + */ + + } else { + scopeMethods.create = scopeMethod(definition, 'create'); + scopeMethods.build = scopeMethod(definition, 'build'); + } + // Mix the property and scoped methods into the prototype class defineScope(modelFrom.prototype, params.through || modelTo, relationName, function () { var filter = {}; filter.where = {}; filter.where[fk] = this[idName]; + + definition.applyScope(this, filter); + if (params.through) { filter.collect = i8n.camelize(modelTo.modelName, true); filter.include = filter.collect; @@ -365,61 +484,191 @@ RelationDefinition.hasMany = function hasMany(modelFrom, modelTo, params) { }; function scopeMethod(definition, methodName) { + var relationClass = RelationClasses[definition.type]; + if (definition.type === RelationTypes.hasMany && definition.modelThrough) { + relationClass = RelationClasses.hasManyThrough; + } var method = function () { - var relationClass = RelationClasses[definition.type]; - if (definition.type === RelationTypes.hasMany && definition.modelThrough) { - relationClass = RelationClasses.hasManyThrough; - } var relation = new relationClass(definition, this); return relation[methodName].apply(relation, arguments); }; + + var relationMethod = relationClass.prototype[methodName]; + if (relationMethod.shared) { + method.shared = true; + method.accepts = relationMethod.accepts; + method.returns = relationMethod.returns; + method.http = relationMethod.http; + method.description = relationMethod.description; + } return method; } -HasMany.prototype.findById = function (id, cb) { +/** + * Find a related item by foreign key + * @param {*} fkId The foreign key + * @param {Function} cb The callback function + */ +HasMany.prototype.findById = function (fkId, cb) { var modelTo = this.definition.modelTo; var fk = this.definition.keyTo; var pk = this.definition.keyFrom; var modelInstance = this.modelInstance; - modelTo.findById(id, function (err, inst) { + + var idName = this.definition.modelTo.definition.idName(); + var filter = {}; + filter.where = {}; + filter.where[idName] = fkId; + filter.where[fk] = modelInstance[pk]; + + this.definition.applyScope(modelInstance, filter); + + modelTo.findOne(filter, function (err, inst) { if (err) { return cb(err); } if (!inst) { - return cb(new Error('Not found')); + err = new Error('No instance with id ' + fkId + ' found for ' + modelTo.modelName); + err.statusCode = 404; + return cb(err); } // Check if the foreign key matches the primary key if (inst[fk] && inst[fk].toString() === modelInstance[pk].toString()) { cb(null, inst); } else { - cb(new Error('Permission denied: foreign key does not match the primary key')); + err = new Error('Key mismatch: ' + this.definition.modelFrom.modelName + '.' + pk + + ': ' + modelInstance[pk] + + ', ' + modelTo.modelName + '.' + fk + ': ' + inst[fk]); + err.statusCode = 400; + cb(err); } }); }; -HasMany.prototype.destroyById = function (id, cb) { - var self = this; +/** + * Find a related item by foreign key + * @param {*} fkId The foreign key + * @param {Function} cb The callback function + */ +HasMany.prototype.exists = function (fkId, cb) { var modelTo = this.definition.modelTo; var fk = this.definition.keyTo; var pk = this.definition.keyFrom; var modelInstance = this.modelInstance; - modelTo.findById(id, function (err, inst) { + + modelTo.findById(fkId, function (err, inst) { if (err) { return cb(err); } if (!inst) { - return cb(new Error('Not found')); + return cb(null, false); } // Check if the foreign key matches the primary key if (inst[fk] && inst[fk].toString() === modelInstance[pk].toString()) { - self.removeFromCache(inst[fk]); - inst.destroy(cb); + cb(null, true); } else { - cb(new Error('Permission denied: foreign key does not match the primary key')); + cb(null, false); } }); }; +/** + * Update a related item by foreign key + * @param {*} fkId The foreign key + * @param {Function} cb The callback function + */ +HasMany.prototype.updateById = function (fkId, data, cb) { + this.findById(fkId, function (err, inst) { + if (err) { + return cb && cb(err); + } + inst.updateAttributes(data, cb); + }); +}; + +/** + * Delete a related item by foreign key + * @param {*} fkId The foreign key + * @param {Function} cb The callback function + */ +HasMany.prototype.destroyById = function (fkId, cb) { + var self = this; + this.findById(fkId, function(err, inst) { + if (err) { + return cb(err); + } + self.removeFromCache(inst[fkId]); + inst.destroy(cb); + }); +}; + +/** + * Find a related item by foreign key + * @param {*} fkId The foreign key value + * @param {Function} cb The callback function + */ +HasManyThrough.prototype.findById = function (fkId, cb) { + var self = this; + var modelTo = this.definition.modelTo; + var pk = this.definition.keyFrom; + var modelInstance = this.modelInstance; + var modelThrough = this.definition.modelThrough; + + self.exists(fkId, function (err, exists) { + if (err || !exists) { + if (!err) { + err = new Error('No relation found in ' + modelThrough.modelName + + ' for (' + self.definition.modelFrom.modelName + '.' + modelInstance[pk] + + ',' + modelTo.modelName + '.' + fkId + ')'); + err.statusCode = 404; + } + return cb(err); + } + modelTo.findById(fkId, function (err, inst) { + if (err) { + return cb(err); + } + if (!inst) { + err = new Error('No instance with id ' + fkId + ' found for ' + modelTo.modelName); + err.statusCode = 404; + return cb(err); + } + cb(err, inst); + }); + }); +}; + +/** + * Delete a related item by foreign key + * @param {*} fkId The foreign key + * @param {Function} cb The callback function + */ +HasManyThrough.prototype.destroyById = function (fkId, cb) { + var self = this; + var modelTo = this.definition.modelTo; + var pk = this.definition.keyFrom; + var modelInstance = this.modelInstance; + var modelThrough = this.definition.modelThrough; + + self.exists(fkId, function (err, exists) { + if (err || !exists) { + if (!err) { + err = new Error('No record found in ' + modelThrough.modelName + + ' for (' + self.definition.modelFrom.modelName + '.' + modelInstance[pk] + + ' ,' + modelTo.modelName + '.' + fkId + ')'); + err.statusCode = 404; + } + return cb(err); + } + self.remove(fkId, function(err) { + if(err) { + return cb(err); + } + modelTo.deleteById(fkId, cb); + }); + }); +}; + // Create an instance of the target model and connect it to the instance of // the source model by creating an instance of the through model HasManyThrough.prototype.create = function create(data, done) { @@ -448,6 +697,9 @@ HasManyThrough.prototype.create = function create(data, done) { var d = {}; d[fk1] = modelInstance[definition.keyFrom]; d[fk2] = to[pk2]; + + definition.applyProperties(modelInstance, d); + // Then create the through model modelThrough.create(d, function (e, through) { if (e) { @@ -483,15 +735,20 @@ HasManyThrough.prototype.add = function (acInst, done) { var pk2 = definition.modelTo.definition.idName(); var fk2 = findBelongsTo(modelThrough, definition.modelTo, pk2); - + query[fk1] = this.modelInstance[pk1]; - query[fk2] = acInst[pk2] || acInst; + query[fk2] = (acInst instanceof definition.modelTo) ? acInst[pk2] : acInst; + + var filter = { where: query }; + + definition.applyScope(this.modelInstance, filter); data[fk1] = this.modelInstance[pk1]; - data[fk2] = acInst[pk2] || acInst; + data[fk2] = (acInst instanceof definition.modelTo) ? acInst[pk2] : acInst; + definition.applyProperties(this.modelInstance, data); // Create an instance of the through model - modelThrough.findOrCreate({where: query}, data, function(err, ac) { + modelThrough.findOrCreate(filter, data, function(err, ac) { if(!err) { if (acInst instanceof definition.modelTo) { self.addToCache(acInst); @@ -501,6 +758,38 @@ HasManyThrough.prototype.add = function (acInst, done) { }); }; +/** + * Check if the target model instance is related to the 'hasMany' relation + * @param {Object|ID} acInst The actual instance or id value + */ +HasManyThrough.prototype.exists = function (acInst, done) { + var definition = this.definition; + var modelThrough = definition.modelThrough; + var pk1 = definition.keyFrom; + + var data = {}; + var query = {}; + + var fk1 = findBelongsTo(modelThrough, definition.modelFrom, + definition.keyFrom); + + // The primary key for the target model + var pk2 = definition.modelTo.definition.idName(); + + var fk2 = findBelongsTo(modelThrough, definition.modelTo, pk2); + + query[fk1] = this.modelInstance[pk1]; + + query[fk2] = (acInst instanceof definition.modelTo) ? acInst[pk2] : acInst; + + data[fk1] = this.modelInstance[pk1]; + data[fk2] = (acInst instanceof definition.modelTo) ? acInst[pk2] : acInst; + + modelThrough.count(query, function(err, ac) { + done(err, ac > 0); + }); +}; + /** * Remove the target model instance from the 'hasMany' relation * @param {Object|ID) acInst The actual instance or id value @@ -522,9 +811,13 @@ HasManyThrough.prototype.remove = function (acInst, done) { var fk2 = findBelongsTo(modelThrough, definition.modelTo, pk2); query[fk1] = this.modelInstance[pk1]; - query[fk2] = acInst[pk2] || acInst; + query[fk2] = (acInst instanceof definition.modelTo) ? acInst[pk2] : acInst; + + var filter = { where: query }; + + definition.applyScope(this.modelInstance, filter); - modelThrough.deleteAll(query, function (err) { + modelThrough.deleteAll(filter.where, function (err) { if (!err) { self.removeFromCache(query[fk2]); } @@ -572,7 +865,7 @@ RelationDefinition.belongsTo = function (modelFrom, modelTo, params) { var idName = modelFrom.dataSource.idName(modelTo.modelName) || 'id'; var relationName = params.as || i8n.camelize(modelTo.modelName, true); var fk = params.foreignKey || relationName + 'Id'; - + var relationDef = modelFrom.relations[relationName] = new RelationDefinition({ name: relationName, type: RelationTypes.belongsTo, @@ -615,6 +908,11 @@ BelongsTo.prototype.create = function(targetModelData, cb) { var pk = this.definition.keyFrom; var modelInstance = this.modelInstance; + if (typeof targetModelData === 'function' && !cb) { + cb = targetModelData; + targetModelData = {}; + } + modelTo.create(targetModelData, function(err, targetModel) { if(!err) { modelInstance[fk] = targetModel[pk]; @@ -648,7 +946,7 @@ BelongsTo.prototype.related = function (refresh, params) { var pk = this.definition.keyTo; var fk = this.definition.keyFrom; var modelInstance = this.modelInstance; - + if (arguments.length === 1) { params = refresh; refresh = false; @@ -678,7 +976,11 @@ BelongsTo.prototype.related = function (refresh, params) { self.resetCache(inst); cb(null, inst); } else { - cb(new Error('Permission denied: foreign key does not match the primary key')); + err = new Error('Key mismatch: ' + self.definition.modelFrom.modelName + '.' + fk + + ': ' + modelInstance[fk] + + ', ' + modelTo.modelName + '.' + pk + ': ' + inst[pk]); + err.statusCode = 400; + cb(err); } }); return modelInstance[fk]; @@ -687,7 +989,7 @@ BelongsTo.prototype.related = function (refresh, params) { return cachedValue; } } else if (params === undefined) { // acts as sync getter - return modelInstance[fk]; + return cachedValue; } else { // setter modelInstance[fk] = params; self.resetCache(); @@ -772,14 +1074,15 @@ RelationDefinition.hasOne = function (modelFrom, modelTo, params) { var relationName = params.as || i8n.camelize(modelTo.modelName, true); var fk = params.foreignKey || i8n.camelize(modelFrom.modelName + '_id', true); - + var relationDef = modelFrom.relations[relationName] = new RelationDefinition({ name: relationName, type: RelationTypes.hasOne, modelFrom: modelFrom, keyFrom: pk, keyTo: fk, - modelTo: modelTo + modelTo: modelTo, + properties: params.properties }); modelFrom.dataSource.defineForeignKey(modelTo.modelName, fk, modelFrom.modelName); @@ -805,38 +1108,93 @@ RelationDefinition.hasOne = function (modelFrom, modelTo, params) { * @param {String|Object} err Error string or object * @param {Object} The newly created target model instance */ -HasOne.prototype.create = function(targetModelData, cb) { +HasOne.prototype.create = function (targetModelData, cb) { var self = this; var modelTo = this.definition.modelTo; var fk = this.definition.keyTo; var pk = this.definition.keyFrom; var modelInstance = this.modelInstance; - var relationName = this.definition.name + if (typeof targetModelData === 'function' && !cb) { + cb = targetModelData; + targetModelData = {}; + } targetModelData = targetModelData || {}; targetModelData[fk] = modelInstance[pk]; + var query = {where: {}}; + query.where[fk] = targetModelData[fk]; + + this.definition.applyScope(modelInstance, query); + this.definition.applyProperties(modelInstance, targetModelData); + + modelTo.findOne(query, function(err, result) { + if(err) { + cb(err); + } else if(result) { + cb(new Error('HasOne relation cannot create more than one instance of ' + + modelTo.modelName)); + } else { + modelTo.create(targetModelData, function (err, targetModel) { + if (!err) { + // Refresh the cache + self.resetCache(targetModel); + cb && cb(err, targetModel); + } else { + cb && cb(err); + } + }); + } + }); +}; + +/** + * Create a target model instance + * @param {Object} targetModelData The target model data + * @callback {Function} [cb] Callback function + * @param {String|Object} err Error string or object + * @param {Object} The newly created target model instance + */ +HasMany.prototype.create = function (targetModelData, cb) { + var self = this; + var modelTo = this.definition.modelTo; + var fk = this.definition.keyTo; + var pk = this.definition.keyFrom; + var modelInstance = this.modelInstance; + + if (typeof targetModelData === 'function' && !cb) { + cb = targetModelData; + targetModelData = {}; + } + targetModelData = targetModelData || {}; + targetModelData[fk] = modelInstance[pk]; + + this.definition.applyProperties(modelInstance, targetModelData); + modelTo.create(targetModelData, function(err, targetModel) { if(!err) { // Refresh the cache - self.resetCache(targetModel); + self.addToCache(targetModel); cb && cb(err, targetModel); } else { cb && cb(err); } }); }; - /** * Build a target model instance * @param {Object} targetModelData The target model data * @returns {Object} The newly built target model instance */ -HasOne.prototype.build = function(targetModelData) { +HasMany.prototype.build = HasOne.prototype.build = function(targetModelData) { var modelTo = this.definition.modelTo; var pk = this.definition.keyFrom; var fk = this.definition.keyTo; + targetModelData = targetModelData || {}; targetModelData[fk] = this.modelInstance[pk]; + + this.definition.applyProperties(this.modelInstance, targetModelData); + return new modelTo(targetModelData); }; @@ -856,8 +1214,8 @@ HasOne.prototype.related = function (refresh, params) { var modelTo = this.definition.modelTo; var fk = this.definition.keyTo; var pk = this.definition.keyFrom; + var definition = this.definition; var modelInstance = this.modelInstance; - var relationName = this.definition.name; if (arguments.length === 1) { params = refresh; @@ -878,6 +1236,7 @@ HasOne.prototype.related = function (refresh, params) { if (cachedValue === undefined) { var query = {where: {}}; query.where[fk] = modelInstance[pk]; + definition.applyScope(modelInstance, query); modelTo.findOne(query, function (err, inst) { if (err) { return cb(err); @@ -890,7 +1249,11 @@ HasOne.prototype.related = function (refresh, params) { self.resetCache(inst); cb(null, inst); } else { - cb(new Error('Permission denied')); + err = new Error('Key mismatch: ' + self.definition.modelFrom.modelName + '.' + pk + + ': ' + modelInstance[pk] + + ', ' + modelTo.modelName + '.' + fk + ': ' + inst[fk]); + err.statusCode = 400; + cb(err); } }); return modelInstance[pk]; @@ -899,7 +1262,7 @@ HasOne.prototype.related = function (refresh, params) { return cachedValue; } } else if (params === undefined) { // acts as sync getter - return modelInstance[pk]; + return cachedValue; } else { // setter params[fk] = modelInstance[pk]; self.resetCache(); diff --git a/lib/scope.js b/lib/scope.js index 7d8d8e75..51c97002 100644 --- a/lib/scope.js +++ b/lib/scope.js @@ -5,6 +5,7 @@ var defineCachedRelations = utils.defineCachedRelations; * Module exports */ exports.defineScope = defineScope; +exports.mergeQuery = mergeQuery; function ScopeDefinition(definition) { this.sourceModel = definition.sourceModel; @@ -186,7 +187,9 @@ function defineScope(cls, targetClass, name, params, methods) { var prop = targetClass.definition.properties[i]; if (prop) { var val = where[i]; - if (typeof val !== 'object' || val instanceof prop.type) { + if (typeof val !== 'object' || val instanceof prop.type + || prop.type.name === 'ObjectID') // MongoDB key + { // Only pick the {propertyName: propertyValue} data[i] = where[i]; } diff --git a/lib/validations.js b/lib/validations.js index aba9c276..9c67a6af 100644 --- a/lib/validations.js +++ b/lib/validations.js @@ -46,6 +46,20 @@ function Validatable() { */ Validatable.validatesPresenceOf = getConfigurator('presence'); +/** + * Validate absence of one or more specified properties. + * A model should not include a property to be considered valid; fails when validated field not blank. + * + * For example, validate absence of reserved + * ``` + * Post.validatesAbsenceOf('reserved', { unless: 'special' }); + * + * @param {String} propertyName One or more property names. + * @options {Object} errMsg Optional custom error message. Default is "can't be set" + * @property {String} message Error message to use instead of default. + */ +Validatable.validatesAbsenceOf = getConfigurator('absence'); + /** * Validate length. Require a property length to be within a specified range. * Three kinds of validations: min, max, is. @@ -225,6 +239,15 @@ function validatePresence(attr, conf, err) { } } +/*! + * Absence validator + */ +function validateAbsence(attr, conf, err) { + if (!blank(this[attr])) { + err(); + } +} + /*! * Length validator */ @@ -305,6 +328,9 @@ function validateCustom(attr, conf, err, done) { * Uniqueness validator */ function validateUniqueness(attr, conf, err, done) { + if (blank(this[attr])) { + return process.nextTick(done); + } var cond = {where: {}}; cond.where[attr] = this[attr]; @@ -331,6 +357,7 @@ function validateUniqueness(attr, conf, err, done) { var validators = { presence: validatePresence, + absence: validateAbsence, length: validateLength, numericality: validateNumericality, inclusion: validateInclusion, @@ -389,7 +416,7 @@ Validatable.prototype.isValid = function (callback, data) { validationsDone.call(inst, function () { callback(valid); }); - }); + }, data, callback); } return valid; } @@ -440,7 +467,7 @@ Validatable.prototype.isValid = function (callback, data) { } } - }, data); + }, data, callback); if (async) { // in case of async validation we should return undefined here, @@ -469,8 +496,11 @@ function validationFailed(inst, v, cb) { // here we should check skip validation conditions (if, unless) // that can be specified in conf - if (skipValidation(inst, conf, 'if')) return false; - if (skipValidation(inst, conf, 'unless')) return false; + if (skipValidation(inst, conf, 'if') + || skipValidation(inst, conf, 'unless')) { + if (cb) cb(true); + return false; + } var fail = false; var validator = validators[conf.validation]; @@ -478,7 +508,7 @@ function validationFailed(inst, v, cb) { validatorArguments.push(attr); validatorArguments.push(conf); validatorArguments.push(function onerror(kind) { - var message, code = conf.validation; + var message, code = conf.code || conf.validation; if (conf.message) { message = conf.message; } @@ -499,7 +529,7 @@ function validationFailed(inst, v, cb) { message = 'is invalid'; } } - inst.errors.add(attr, message, code); + if (kind !== false) inst.errors.add(attr, message, code); fail = true; }); if (cb) { @@ -532,6 +562,7 @@ function skipValidation(inst, conf, kind) { var defaultMessages = { presence: 'can\'t be blank', + absence: 'can\'t be set', length: { min: 'too short', max: 'too long', diff --git a/package.json b/package.json index 6a06c245..a04737a1 100644 --- a/package.json +++ b/package.json @@ -24,15 +24,14 @@ ], "devDependencies": { "should": "~1.2.2", - "mocha": "~1.18.2" + "mocha": "~1.20.1" }, "dependencies": { "async": "~0.9.0", - "inflection": "~1.3.5", "loopback-connector": "1.x", - "traverse": "~0.6.6", + "debug": "~1.0.2", "qs": "~0.6.6", - "debug": "~0.8.1" + "traverse": "~0.6.6" }, "license": { "name": "Dual MIT/StrongLoop", diff --git a/test/hooks.test.js b/test/hooks.test.js index f613122b..b1502f99 100644 --- a/test/hooks.test.js +++ b/test/hooks.test.js @@ -81,7 +81,27 @@ describe('hooks', function () { } User.afterCreate = function () { - throw new Error('shouldn\'t be called') + throw new Error('shouldn\'t be called'); + }; + User.create(function (err, user) { + User.dataSource.connector.create = old; + done(); + }); + }); + + it('afterCreate should not be triggered on failed beforeCreate', function (done) { + User.beforeCreate = function (next, data) { + // Skip next() + next(new Error('fail in beforeCreate')); + }; + + var old = User.dataSource.connector.create; + User.dataSource.connector.create = function (modelName, id, cb) { + throw new Error('shouldn\'t be called'); + } + + User.afterCreate = function () { + throw new Error('shouldn\'t be called'); }; User.create(function (err, user) { User.dataSource.connector.create = old; @@ -173,6 +193,18 @@ describe('hooks', function () { }); }); + it('beforeSave should be able to skip next', function (done) { + User.create(function (err, user) { + User.beforeSave = function (next, data) { + next(null, 'XYZ'); + }; + user.save(function(err, result) { + result.should.be.eql('XYZ'); + done(); + }); + }); + }); + }); describe('update', function () { @@ -221,7 +253,7 @@ describe('hooks', function () { it('should not trigger after-hook on failed save', function (done) { User.afterUpdate = function () { - should.fail('afterUpdate shouldn\'t be called') + should.fail('afterUpdate shouldn\'t be called'); }; User.create(function (err, user) { var save = User.dataSource.connector.save; diff --git a/test/loopback-dl.test.js b/test/loopback-dl.test.js index aeda8992..62dbf1cc 100644 --- a/test/loopback-dl.test.js +++ b/test/loopback-dl.test.js @@ -1285,6 +1285,39 @@ describe('Load models from json', function () { } }); + it('should allow customization of default model base class', function () { + var modelBuilder = new ModelBuilder(); + + var User = modelBuilder.define('User', { + name: String, + bio: ModelBuilder.Text, + approved: Boolean, + joinedAt: Date, + age: Number + }); + + modelBuilder.defaultModelBaseClass = User; + + var Customer = modelBuilder.define('Customer', {customerId: {type: String, id: true}}); + assert(Customer.prototype instanceof User); + }); + + it('should allow model base class', function () { + var modelBuilder = new ModelBuilder(); + + var User = modelBuilder.define('User', { + name: String, + bio: ModelBuilder.Text, + approved: Boolean, + joinedAt: Date, + age: Number + }); + + var Customer = modelBuilder.define('Customer', + {customerId: {type: String, id: true}}, {}, User); + assert(Customer.prototype instanceof User); + }); + it('should be able to extend models', function (done) { var modelBuilder = new ModelBuilder(); diff --git a/test/memory.test.js b/test/memory.test.js index 4016209e..40283b82 100644 --- a/test/memory.test.js +++ b/test/memory.test.js @@ -141,6 +141,64 @@ describe('Memory connector', function () { }); }); + 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('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 throw if order has wrong direction', function (done) { + User.find({order: 'seq ABC'}, function (err, posts) { + should.exist(err); + done(); + }); + }); + function seed(done) { var beatles = [ { diff --git a/test/relations.test.js b/test/relations.test.js index 4e1f5873..0691ded8 100644 --- a/test/relations.test.js +++ b/test/relations.test.js @@ -1,32 +1,31 @@ // This test written in mocha+should.js var should = require('./init.js'); -var db, Book, Chapter, Author, Reader, Publisher; +var db, Book, Chapter, Author, Reader; +var Category, Product; describe('relations', function () { - before(function (done) { - db = getSchema(); - Book = db.define('Book', {name: String}); - Chapter = db.define('Chapter', {name: {type: String, index: true}}); - Author = db.define('Author', {name: String}); - Reader = db.define('Reader', {name: String}); - db.automigrate(function () { - Book.destroyAll(function () { - Chapter.destroyAll(function () { - Author.destroyAll(function () { - Reader.destroyAll(done); + describe('hasMany', function () { + before(function (done) { + db = getSchema(); + Book = db.define('Book', {name: String, type: String}); + Chapter = db.define('Chapter', {name: {type: String, index: true}, + bookType: String}); + Author = db.define('Author', {name: String}); + Reader = db.define('Reader', {name: String}); + + db.automigrate(function () { + Book.destroyAll(function () { + Chapter.destroyAll(function () { + Author.destroyAll(function () { + Reader.destroyAll(done); + }); }); }); }); }); - }); - after(function () { - // db.disconnect(); - }); - - describe('hasMany', function () { it('can be declared in different ways', function (done) { Book.hasMany(Chapter); Book.hasMany(Reader, {as: 'users'}); @@ -73,12 +72,12 @@ describe('relations', function () { book.chapters.create({name: 'a'}, function () { book.chapters.create({name: 'z'}, function () { book.chapters.create({name: 'c'}, function () { - fetch(book); + verify(book); }); }); }); }); - function fetch(book) { + function verify(book) { book.chapters(function (err, ch) { should.not.exist(err); should.exist(ch); @@ -102,14 +101,170 @@ describe('relations', function () { id = ch.id; book.chapters.create({name: 'z'}, function () { book.chapters.create({name: 'c'}, function () { - fetch(book); + verify(book); }); }); }); }); - function fetch(book) { + function verify(book) { book.chapters.findById(id, function (err, ch) { + should.not.exist(err); + should.exist(ch); + ch.id.should.eql(id); + done(); + }); + } + }); + + it('should set targetClass on scope property', function() { + should.equal(Book.prototype.chapters._targetClass, 'Chapter'); + }); + + it('should update scoped record', function (done) { + var id; + Book.create(function (err, book) { + book.chapters.create({name: 'a'}, function (err, ch) { + id = ch.id; + book.chapters.updateById(id, {name: 'aa'}, function(err, ch) { + verify(book); + }); + }); + }); + + function verify(book) { + book.chapters.findById(id, function (err, ch) { + should.not.exist(err); + should.exist(ch); + ch.id.should.eql(id); + ch.name.should.equal('aa'); + done(); + }); + } + }); + + it('should destroy scoped record', function (done) { + var id; + Book.create(function (err, book) { + book.chapters.create({name: 'a'}, function (err, ch) { + id = ch.id; + book.chapters.destroy(id, function(err, ch) { + verify(book); + }); + }); + }); + + function verify(book) { + book.chapters.findById(id, function (err, ch) { + should.exist(err); + done(); + }); + } + }); + + it('should check existence of a scoped record', function (done) { + var id; + Book.create(function (err, book) { + book.chapters.create({name: 'a'}, function (err, ch) { + id = ch.id; + book.chapters.create({name: 'z'}, function () { + book.chapters.create({name: 'c'}, function () { + verify(book); + }); + }); + }); + }); + + function verify(book) { + book.chapters.exists(id, function (err, flag) { + should.not.exist(err); + flag.should.be.eql(true); + done(); + }); + } + }); + }); + + describe('hasMany through', function () { + var Physician, Patient, Appointment; + + before(function (done) { + db = getSchema(); + Physician = db.define('Physician', {name: String}); + Patient = db.define('Patient', {name: String}); + Appointment = db.define('Appointment', {date: {type: Date, + default: function () { + return new Date(); + }}}); + + Physician.hasMany(Patient, {through: Appointment}); + Patient.hasMany(Physician, {through: Appointment}); + Appointment.belongsTo(Patient); + Appointment.belongsTo(Physician); + + db.automigrate(['Physician', 'Patient', 'Appointment'], function (err) { + done(err); + }); + }); + + it('should build record on scope', function (done) { + Physician.create(function (err, physician) { + var patient = physician.patients.build(); + patient.physicianId.should.equal(physician.id); + patient.save(done); + }); + }); + + it('should create record on scope', function (done) { + Physician.create(function (err, physician) { + physician.patients.create(function (err, patient) { + should.not.exist(err); + should.exist(patient); + Appointment.find({where: {physicianId: physician.id, patientId: patient.id}}, + function(err, apps) { + should.not.exist(err); + apps.should.have.lengthOf(1); + done(); + }); + }); + }); + }); + + it('should fetch all scoped instances', function (done) { + Physician.create(function (err, physician) { + physician.patients.create({name: 'a'}, function () { + physician.patients.create({name: 'z'}, function () { + physician.patients.create({name: 'c'}, function () { + verify(physician); + }); + }); + }); + }); + function verify(physician) { + physician.patients(function (err, ch) { + should.not.exist(err); + should.exist(ch); + ch.should.have.lengthOf(3); + done(); + }); + } + }); + + it('should find scoped record', function (done) { + var id; + Physician.create(function (err, physician) { + physician.patients.create({name: 'a'}, function (err, ch) { + id = ch.id; + physician.patients.create({name: 'z'}, function () { + physician.patients.create({name: 'c'}, function () { + verify(physician); + }); + }); + }); + }); + + function verify(physician) { + physician.patients.findById(id, function (err, ch) { should.not.exist(err); should.exist(ch); ch.id.should.equal(id); @@ -119,8 +274,230 @@ describe('relations', function () { }); it('should set targetClass on scope property', function() { - should.equal(Book.prototype.chapters._targetClass, 'Chapter'); + should.equal(Physician.prototype.patients._targetClass, 'Patient'); }); + + it('should update scoped record', function (done) { + var id; + Physician.create(function (err, physician) { + physician.patients.create({name: 'a'}, function (err, ch) { + id = ch.id; + physician.patients.updateById(id, {name: 'aa'}, function(err, ch) { + verify(physician); + }); + }); + }); + + function verify(physician) { + physician.patients.findById(id, function (err, ch) { + should.not.exist(err); + should.exist(ch); + ch.id.should.equal(id); + ch.name.should.equal('aa'); + done(); + }); + } + }); + + it('should destroy scoped record', function (done) { + var id; + Physician.create(function (err, physician) { + physician.patients.create({name: 'a'}, function (err, ch) { + id = ch.id; + physician.patients.destroy(id, function(err, ch) { + verify(physician); + }); + }); + }); + + function verify(physician) { + physician.patients.findById(id, function (err, ch) { + should.exist(err); + done(); + }); + } + }); + + it('should check existence of a scoped record', function (done) { + var id; + Physician.create(function (err, physician) { + physician.patients.create({name: 'a'}, function (err, ch) { + id = ch.id; + physician.patients.create({name: 'z'}, function () { + physician.patients.create({name: 'c'}, function () { + verify(physician); + }); + }); + }); + }); + + function verify(physician) { + physician.patients.exists(id, function (err, flag) { + should.not.exist(err); + flag.should.be.eql(true); + done(); + }); + } + }); + + it('should allow to add connection with instance', function (done) { + Physician.create({name: 'ph1'}, function (e, physician) { + Patient.create({name: 'pa1'}, function (e, patient) { + physician.patients.add(patient, function (e, app) { + should.not.exist(e); + should.exist(app); + app.should.be.an.instanceOf(Appointment); + app.physicianId.should.equal(physician.id); + app.patientId.should.equal(patient.id); + done(); + }); + }); + }); + }); + + it('should allow to remove connection with instance', function (done) { + var id; + Physician.create(function (err, physician) { + physician.patients.create({name: 'a'}, function (err, patient) { + id = patient.id; + physician.patients.remove(id, function (err, ch) { + verify(physician); + }); + }); + }); + + function verify(physician) { + physician.patients.exists(id, function (err, flag) { + should.not.exist(err); + flag.should.be.eql(false); + done(); + }); + } + }); + + beforeEach(function (done) { + Appointment.destroyAll(function (err) { + Physician.destroyAll(function (err) { + Patient.destroyAll(done); + }); + }); + }); + + }); + + describe('hasMany with properties', function () { + it('can be declared with properties', function (done) { + Book.hasMany(Chapter, { properties: { type: 'bookType' } }); + db.automigrate(done); + }); + + it('should create record on scope', function (done) { + Book.create({ type: 'fiction' }, function (err, book) { + book.chapters.create(function (err, c) { + should.not.exist(err); + should.exist(c); + c.bookId.should.equal(book.id); + c.bookType.should.equal('fiction'); + done(); + }); + }); + }); + }); + + describe('hasMany with scope', function () { + it('can be declared with properties', function (done) { + db = getSchema(); + Category = db.define('Category', {name: String, productType: String}); + Product = db.define('Product', {name: String, type: String}); + + Category.hasMany(Product, { + properties: function(inst) { + if (!inst.productType) return; // skip + return { type: inst.productType }; + }, + scope: function(inst, filter) { + var m = this.properties(inst); // re-use properties + if (m) return { where: m }; + } + }); + db.automigrate(done); + }); + + it('should create record on scope', function (done) { + Category.create(function (err, c) { + c.products.create({ type: 'book' }, function(err, p) { + p.categoryId.should.equal(c.id); + p.type.should.equal('book'); + c.products.create({ type: 'widget' }, function(err, p) { + p.categoryId.should.equal(c.id); + p.type.should.equal('widget'); + done(); + }); + }); + }); + }); + + it('should find record on scope', function (done) { + Category.findOne(function (err, c) { + c.products(function(err, products) { + products.should.have.length(2); + done(); + }); + }); + }); + + it('should find record on scope - filtered', function (done) { + Category.findOne(function (err, c) { + c.products({ where: { type: 'book' } }, function(err, products) { + products.should.have.length(1); + products[0].type.should.equal('book'); + done(); + }); + }); + }); + + // So why not just do the above? In LoopBack, the context + // that gets passed into a beforeRemote handler contains + // a reference to the parent scope/instance: ctx.instance + // in order to enforce a (dynamic scope) at runtime + // a temporary property can be set in the beforeRemoting + // handler. Optionally,properties dynamic properties can be declared. + // + // The code below simulates this. + + it('should create record on scope - properties', function (done) { + Category.findOne(function (err, c) { + c.productType = 'tool'; // temporary + c.products.create(function(err, p) { + p.categoryId.should.equal(c.id); + p.type.should.equal('tool'); + done(); + }); + }); + }); + + it('should find record on scope - scoped', function (done) { + Category.findOne(function (err, c) { + c.productType = 'book'; // temporary, for scoping + c.products(function(err, products) { + products.should.have.length(1); + products[0].type.should.equal('book'); + done(); + }); + }); + }); + + it('should find record on scope - scoped', function (done) { + Category.findOne(function (err, c) { + c.productType = 'tool'; // temporary, for scoping + c.products(function(err, products) { + products.should.have.length(1); + products[0].type.should.equal('tool'); + done(); + }); + }); + }); + }); describe('belongsTo', function () { @@ -155,7 +532,7 @@ describe('relations', function () { should.not.exist(e); should.exist(l); l.should.be.an.instanceOf(List); - todo.list().should.equal(l.id); + todo.list().id.should.equal(l.id); done(); }); }); @@ -186,11 +563,11 @@ describe('relations', function () { before(function () { db = getSchema(); Supplier = db.define('Supplier', {name: String}); - Account = db.define('Account', {accountNo: String}); + Account = db.define('Account', {accountNo: String, supplierName: String}); }); it('can be declared using hasOne method', function () { - Supplier.hasOne(Account); + Supplier.hasOne(Account, { properties: { name: 'supplierName' } }); Object.keys((new Account()).toObject()).should.include('supplierId'); (new Supplier()).account.should.be.an.instanceOf(Function); }); @@ -206,7 +583,8 @@ describe('relations', function () { should.not.exist(e); should.exist(act); act.should.be.an.instanceOf(Account); - supplier.account().should.equal(act.id); + supplier.account().id.should.equal(act.id); + act.supplierName.should.equal(supplier.name); done(); }); }); diff --git a/test/validations.test.js b/test/validations.test.js index c3b5c8d4..935a2669 100644 --- a/test/validations.test.js +++ b/test/validations.test.js @@ -159,6 +159,20 @@ describe('validations', function () { }); }); + + describe('absence', function () { + + it('should validate absence', function () { + User.validatesAbsenceOf('reserved', { if: 'locked' }); + var u = new User({reserved: 'foo', locked: true}); + u.isValid().should.not.be.true; + u.reserved = null; + u.isValid().should.be.true; + var u = new User({reserved: 'foo', locked: false}); + u.isValid().should.be.true; + }); + + }); describe('uniqueness', function () { it('should validate uniqueness', function (done) { @@ -227,6 +241,33 @@ describe('validations', function () { done(err); }); }); + + it('should skip blank values', function (done) { + User.validatesUniquenessOf('email'); + var u = new User({email: ' '}); + Boolean(u.isValid(function (valid) { + valid.should.be.true; + u.save(function () { + var u2 = new User({email: null}); + u2.isValid(function (valid) { + valid.should.be.true; + done(); + }); + }); + })).should.be.false; + }); + + it('should work with if/unless', function (done) { + User.validatesUniquenessOf('email', { + if: function() { return true; }, + unless: function() { return false; } + }); + var u = new User({email: 'hello'}); + Boolean(u.isValid(function (valid) { + valid.should.be.true; + done(); + })).should.be.false; + }); }); describe('format', function () { @@ -251,7 +292,40 @@ describe('validations', function () { }); describe('custom', function () { - it('should validate using custom sync validation'); - it('should validate using custom async validation'); + it('should validate using custom sync validation', function() { + User.validate('email', function (err) { + if (this.email === 'hello') err(); + }, { code: 'invalid-email' }); + var u = new User({email: 'hello'}); + Boolean(u.isValid()).should.be.false; + u.errors.codes.should.eql({ email: ['invalid-email'] }); + }); + + it('should validate and return detailed error messages', function() { + User.validate('global', function (err) { + if (this.email === 'hello' || this.email === 'hey') { + this.errors.add('email', 'Cannot be `' + this.email + '`', 'invalid-email'); + err(false); // false: prevent global error message + } + }); + var u = new User({email: 'hello'}); + Boolean(u.isValid()).should.be.false; + u.errors.should.eql({ email: ['Cannot be `hello`'] }); + u.errors.codes.should.eql({ email: ['invalid-email'] }); + }); + + it('should validate using custom async validation', function(done) { + User.validateAsync('email', function (err, next) { + process.nextTick(next); + }, { + if: function() { return true; }, + unless: function() { return false; } + }); + var u = new User({email: 'hello'}); + Boolean(u.isValid(function (valid) { + valid.should.be.true; + done(); + })).should.be.false; + }); }); });