diff --git a/index.js b/index.js index e3eacac0..a1e5420e 100644 --- a/index.js +++ b/index.js @@ -1,6 +1,7 @@ exports.ModelBuilder = exports.LDL = require('./lib/model-builder.js').ModelBuilder; exports.DataSource = exports.Schema = require('./lib/datasource.js').DataSource; exports.ModelBaseClass = require('./lib/model.js'); +exports.GeoPoint = require('./lib/geo.js').GeoPoint; exports.ValidationError = require('./lib/validations.js').ValidationError; exports.__defineGetter__('version', function () { diff --git a/lib/connectors/memory.js b/lib/connectors/memory.js index 829cca34..97687cf2 100644 --- a/lib/connectors/memory.js +++ b/lib/connectors/memory.js @@ -17,6 +17,7 @@ exports.initialize = function initializeDataSource(dataSource, callback) { }; exports.Memory = Memory; +exports.applyFilter = applyFilter; function Memory(m, settings) { if (m instanceof Memory) { diff --git a/lib/dao.js b/lib/dao.js index a1508eb1..7f51ab90 100644 --- a/lib/dao.js +++ b/lib/dao.js @@ -14,6 +14,7 @@ var Relation = require('./relations.js'); var Inclusion = require('./include.js'); var List = require('./list.js'); var geo = require('./geo'); +var mergeQuery = require('./scope.js').mergeQuery; var Memory = require('./connectors/memory').Memory; var utils = require('./utils'); var fieldsToArray = utils.fieldsToArray; @@ -324,6 +325,47 @@ DataAccessObject.findById = function find(id, cb) { }.bind(this)); }; +DataAccessObject.findByIds = function(ids, cond, cb) { + if (typeof cond === 'function') { + cb = cond; + cond = {}; + } + + var pk = this.dataSource.idName(this.modelName) || 'id'; + if (ids.length === 0) { + process.nextTick(function() { cb(null, []); }); + return; + } + + var filter = { where: {} }; + filter.where[pk] = { inq: ids }; + mergeQuery(filter, cond || {}); + this.find(filter, function(err, results) { + cb(err, err ? results : this.sortByIds(ids, results)); + }.bind(this)); +}; + +DataAccessObject.sortByIds = function(ids, results) { + var pk = this.dataSource.idName(this.modelName) || 'id'; + ids = ids.map(function(id) { + return (typeof id === 'object') ? id.toString() : id; + }); + + results.sort(function(x, y) { + var idA = (typeof x[pk] === 'object') ? x[pk].toString() : x[pk]; + var idB = (typeof y[pk] === 'object') ? y[pk].toString() : y[pk]; + var a = ids.indexOf(idA); + var b = ids.indexOf(idB); + if (a === -1 || b === -1) return 1; // last + if (a !== b) { + if (a > b) return 1; + if (a < b) return -1; + } + }); + + return results; +}; + function convertNullToNotFoundError(ctx, cb) { if (ctx.result !== null) return cb(); @@ -1046,6 +1088,17 @@ DataAccessObject.prototype.remove = }.bind(this)); }, null, cb); }; + +/** + * Set a single attribute. + * Equivalent to `setAttributes({name: value})` + * + * @param {String} name Name of property + * @param {Mixed} value Value of property + */ +DataAccessObject.prototype.setAttribute = function setAttribute(name, value) { + this[name] = value; +}; /** * Update a single attribute. @@ -1062,7 +1115,27 @@ DataAccessObject.prototype.updateAttribute = function updateAttribute(name, valu }; /** - * Update saet of attributes. + * Update set of attributes. + * + * @trigger `change` hook + * @param {Object} data Data to update + */ +DataAccessObject.prototype.setAttributes = function setAttributes(data) { + if (typeof data !== 'object') return; + + var Model = this.constructor; + var inst = this; + + // update instance's properties + for (var key in data) { + inst.setAttribute(key, data[key]); + } + + Model.emit('set', inst); +}; + +/** + * Update set of attributes. * Performs validation before updating. * * @trigger `validation`, `save` and `update` hooks @@ -1086,9 +1159,7 @@ DataAccessObject.prototype.updateAttributes = function updateAttributes(data, cb } // update instance's properties - for (var key in data) { - inst[key] = data[key]; - } + inst.setAttributes(data); inst.isValid(function (valid) { if (!valid) { diff --git a/lib/datasource.js b/lib/datasource.js index 34f40e0b..1f3846c5 100644 --- a/lib/datasource.js +++ b/lib/datasource.js @@ -12,6 +12,7 @@ var EventEmitter = require('events').EventEmitter; var util = require('util'); var assert = require('assert'); var async = require('async'); +var traverse = require('traverse'); if (process.env.DEBUG === 'loopback') { // For back-compatibility @@ -396,12 +397,9 @@ DataSource.prototype.defineRelations = function (modelClass, relations) { // Check if the through model doesn't exist or resolved if (!throughModel || isModelDataSourceAttached(throughModel)) { // The target model is resolved - var params = { - foreignKey: relation.foreignKey, - as: name, - model: model, - options: relation.options - }; + var params = traverse(relation).clone(); + params.as = name; + params.model = model; if (throughModel) { params.through = throughModel; } @@ -415,13 +413,10 @@ DataSource.prototype.defineRelations = function (modelClass, relations) { throughModel.once('dataSourceAttached', function (model) { if (isModelDataSourceAttached(targetModel)) { // The target model is resolved - var params = { - foreignKey: relation.foreignKey, - as: name, - model: targetModel, - through: model, - options: relation.options - }; + var params = traverse(relations).clone(); + params.as = name; + params.model = targetModel; + params.through = model; modelClass[relation.type].call(modelClass, name, params); } }); @@ -438,17 +433,14 @@ DataSource.prototype.defineRelations = function (modelClass, relations) { if (r.through) { throughModel = isModelClass(r.through) ? r.through : this.getModel(r.through, true); } - if (!isModelDataSourceAttached(targetModel) || (throughModel && !isModelDataSourceAttached(throughModel))) { + if ((!r.polymorphic && !isModelDataSourceAttached(targetModel)) || (throughModel && !isModelDataSourceAttached(throughModel))) { // Create a listener to defer the relation set up createListener(rn, r, targetModel, throughModel); } else { // The target model is resolved - var params = { - foreignKey: r.foreignKey, - as: rn, - model: targetModel, - options: r.options - }; + var params = traverse(r).clone(); + params.as = rn; + params.model = targetModel; if (throughModel) { params.through = throughModel; } diff --git a/lib/include.js b/lib/include.js index 342a2bde..5fe6548c 100644 --- a/lib/include.js +++ b/lib/include.js @@ -94,7 +94,7 @@ Inclusion.include = function (objects, include, cb) { subInclude = null; } var relation = relations[relationName]; - + if (!relation) { cb(new Error('Relation "' + relationName + '" is not defined for ' + self.modelName + ' model')); @@ -106,7 +106,7 @@ Inclusion.include = function (objects, include, cb) { cb(); return; } - + // Calling the relation method for each object async.each(objs, function (obj, callback) { if(relation.type === 'belongsTo') { @@ -133,11 +133,11 @@ Inclusion.include = function (objects, include, cb) { obj.__cachedRelations[relationName] = result; if(obj === inst) { obj.__data[relationName] = result; - obj.strict = false; + obj.setStrict(false); } else { obj[relationName] = result; } - + if (subInclude && result) { var subItems = relation.multiple ? result : [result]; // Recursively include the related models diff --git a/lib/model-builder.js b/lib/model-builder.js index 60758ddf..a688004d 100644 --- a/lib/model-builder.js +++ b/lib/model-builder.js @@ -161,6 +161,9 @@ ModelBuilder.prototype.define = function defineClass(className, properties, sett }; // mix in EventEmitter (don't inherit from) var events = new EventEmitter(); + // The model can have more than 10 listeners for lazy relationship setup + // See https://github.com/strongloop/loopback/issues/404 + events.setMaxListeners(32); for (var f in EventEmitter.prototype) { if (typeof EventEmitter.prototype[f] === 'function') { ModelClass[f] = EventEmitter.prototype[f].bind(events); diff --git a/lib/relation-definition.js b/lib/relation-definition.js index 490d27c2..44532273 100644 --- a/lib/relation-definition.js +++ b/lib/relation-definition.js @@ -7,6 +7,8 @@ var i8n = require('inflection'); var defineScope = require('./scope.js').defineScope; var mergeQuery = require('./scope.js').mergeQuery; var ModelBaseClass = require('./model.js'); +var applyFilter = require('./connectors/memory').applyFilter; +var ValidationError = require('./validations.js').ValidationError; exports.Relation = Relation; exports.RelationDefinition = RelationDefinition; @@ -15,7 +17,9 @@ var RelationTypes = { belongsTo: 'belongsTo', hasMany: 'hasMany', hasOne: 'hasOne', - hasAndBelongsToMany: 'hasAndBelongsToMany' + hasAndBelongsToMany: 'hasAndBelongsToMany', + referencesMany: 'referencesMany', + embedsMany: 'embedsMany' }; exports.RelationTypes = RelationTypes; @@ -24,13 +28,17 @@ exports.HasManyThrough = HasManyThrough; exports.HasOne = HasOne; exports.HasAndBelongsToMany = HasAndBelongsToMany; exports.BelongsTo = BelongsTo; +exports.ReferencesMany = ReferencesMany; +exports.EmbedsMany = EmbedsMany; var RelationClasses = { belongsTo: BelongsTo, hasMany: HasMany, hasManyThrough: HasManyThrough, hasOne: HasOne, - hasAndBelongsToMany: HasAndBelongsToMany + hasAndBelongsToMany: HasAndBelongsToMany, + referencesMany: ReferencesMany, + embedsMany: EmbedsMany }; function normalizeType(type) { @@ -44,7 +52,35 @@ function normalizeType(type) { } } return null; -} +}; + +function extendScopeMethods(definition, scopeMethods, ext) { + var customMethods = []; + var relationClass = RelationClasses[definition.type]; + if (definition.type === RelationTypes.hasMany && definition.modelThrough) { + relationClass = RelationClasses.hasManyThrough; + } + if (typeof ext === 'function') { + customMethods = ext.call(definition, scopeMethods, relationClass); + } else if (typeof ext === 'object') { + for (var key in ext) { + var relationMethod = ext[key]; + var method = scopeMethods[key] = function () { + var relation = new relationClass(definition, this); + return relationMethod.apply(relation, arguments); + }; + if (relationMethod.shared) { + method.shared = true; + method.accepts = relationMethod.accepts; + method.returns = relationMethod.returns; + method.http = relationMethod.http; + method.description = relationMethod.description; + } + customMethods.push(key); + } + } + return [].concat(customMethods || []); +}; /** * Relation definition class. Use to define relationships between models. @@ -57,6 +93,7 @@ function RelationDefinition(definition) { } definition = definition || {}; this.name = definition.name; + this.accessor = definition.accessor || this.name; assert(this.name, 'Relation name is missing'); this.type = normalizeType(definition.type); assert(this.type, 'Invalid relation type: ' + definition.type); @@ -64,14 +101,18 @@ function RelationDefinition(definition) { assert(this.modelFrom, 'Source model is required'); this.keyFrom = definition.keyFrom; this.modelTo = definition.modelTo; - assert(this.modelTo, 'Target model is required'); this.keyTo = definition.keyTo; + this.discriminator = definition.discriminator; + if (!this.discriminator) { + assert(this.modelTo, 'Target model is required'); + } this.modelThrough = definition.modelThrough; this.keyThrough = definition.keyThrough; this.multiple = (this.type !== 'belongsTo' && this.type !== 'hasOne'); this.properties = definition.properties || {}; this.options = definition.options || {}; this.scope = definition.scope; + this.embed = definition.embed === true; } RelationDefinition.prototype.toJSON = function () { @@ -97,13 +138,19 @@ RelationDefinition.prototype.toJSON = function () { * @param {Object} filter (where, order, limit, fields, ...) */ RelationDefinition.prototype.applyScope = function(modelInstance, filter) { + filter = filter || {}; + filter.where = filter.where || {}; + if ((this.type !== 'belongsTo' || this.type === 'hasOne') + && typeof this.discriminator === 'string') { // polymorphic + filter.where[this.discriminator] = this.modelFrom.modelName; + } 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); + } else { + var scope = this.scope; + } + if (typeof scope === 'object') { + mergeQuery(filter, scope); } }; @@ -124,6 +171,10 @@ RelationDefinition.prototype.applyProperties = function(modelInstance, target) { target[key] = modelInstance[k]; } } + if ((this.type !== 'belongsTo' || this.type === 'hasOne') + && typeof this.discriminator === 'string') { // polymorphic + target[this.discriminator] = this.modelFrom.modelName; + } }; /** @@ -278,6 +329,41 @@ function HasOne(definition, modelInstance) { util.inherits(HasOne, Relation); +/** + * EmbedsMany subclass + * @param {RelationDefinition|Object} definition + * @param {Object} modelInstance + * @returns {EmbedsMany} + * @constructor + * @class EmbedsMany + */ +function EmbedsMany(definition, modelInstance) { + if (!(this instanceof EmbedsMany)) { + return new EmbedsMany(definition, modelInstance); + } + assert(definition.type === RelationTypes.embedsMany); + Relation.apply(this, arguments); +} + +util.inherits(EmbedsMany, Relation); + +/** + * ReferencesMany subclass + * @param {RelationDefinition|Object} definition + * @param {Object} modelInstance + * @returns {ReferencesMany} + * @constructor + * @class ReferencesMany + */ +function ReferencesMany(definition, modelInstance) { + if (!(this instanceof ReferencesMany)) { + return new ReferencesMany(definition, modelInstance); + } + assert(definition.type === RelationTypes.referencesMany); + Relation.apply(this, arguments); +} + +util.inherits(ReferencesMany, Relation); /*! * Find the relation by foreign key @@ -316,6 +402,19 @@ function lookupModel(models, modelName) { } } +/*! + * Normalize polymorphic parameters + * @param {Object|String} params Name of the polymorphic relation or params + * @returns {Object} The normalized parameters + */ +function polymorphicParams(params) { + if (typeof params === 'string') params = { as: params }; + if (typeof params.as !== 'string') params.as = 'reference'; // default + params.foreignKey = params.foreignKey || i8n.camelize(params.as + '_id', true); + params.discriminator = params.discriminator || i8n.camelize(params.as + '_type', true); + return params; +} + /** * Define a "one to many" relationship by specifying the model name * @@ -350,10 +449,23 @@ RelationDefinition.hasMany = function hasMany(modelFrom, modelTo, params) { modelTo = lookupModel(modelFrom.dataSource.modelBuilder.models, modelToName); } } + var relationName = params.as || i8n.camelize(modelTo.pluralModelName, true); var fk = params.foreignKey || i8n.camelize(thisClassName + '_id', true); var idName = modelFrom.dataSource.idName(modelFrom.modelName) || 'id'; + var discriminator; + + if (params.polymorphic) { + var polymorphic = polymorphicParams(params.polymorphic); + discriminator = polymorphic.discriminator; + if (!params.invert) { + fk = polymorphic.foreignKey; + } + if (!params.through) { + modelTo.dataSource.defineProperty(modelTo.modelName, discriminator, { type: 'string', index: true }); + } + } var definition = new RelationDefinition({ name: relationName, @@ -361,6 +473,7 @@ RelationDefinition.hasMany = function hasMany(modelFrom, modelTo, params) { modelFrom: modelFrom, keyFrom: idName, keyTo: fk, + discriminator: discriminator, modelTo: modelTo, multiple: true, properties: params.properties, @@ -368,16 +481,17 @@ RelationDefinition.hasMany = function hasMany(modelFrom, modelTo, params) { options: params.options }); - if (params.through) { - definition.modelThrough = params.through; - var keyThrough = definition.throughKey || i8n.camelize(modelTo.modelName + '_id', true); - definition.keyThrough = keyThrough; - } - + definition.modelThrough = params.through; + + var keyThrough = definition.throughKey || i8n.camelize(modelTo.modelName + '_id', true); + definition.keyThrough = keyThrough; + modelFrom.relations[relationName] = definition; if (!params.through) { // obviously, modelTo should have attribute called `fk` + // for polymorphic relations, it is assumed to share the same fk type for all + // polymorphic models modelTo.dataSource.defineForeignKey(modelTo.modelName, fk, modelFrom.modelName); } @@ -397,6 +511,9 @@ RelationDefinition.hasMany = function hasMany(modelFrom, modelTo, params) { var updateByIdFunc = scopeMethods.updateById; modelFrom.prototype['__updateById__' + relationName] = updateByIdFunc; + var existsByIdFunc = scopeMethods.exists; + modelFrom.prototype['__exists__' + relationName] = existsByIdFunc; + if(definition.modelThrough) { scopeMethods.create = scopeMethod(definition, 'create'); scopeMethods.add = scopeMethod(definition, 'add'); @@ -412,6 +529,16 @@ RelationDefinition.hasMany = function hasMany(modelFrom, modelTo, params) { scopeMethods.build = scopeMethod(definition, 'build'); } + var customMethods = extendScopeMethods(definition, scopeMethods, params.scopeMethods); + + for (var i = 0; i < customMethods.length; i++) { + var methodName = customMethods[i]; + var method = scopeMethods[methodName]; + if (typeof method === 'function' && method.shared === true) { + modelFrom.prototype['__' + methodName + '__' + relationName] = method; + } + }; + // Mix the property and scoped methods into the prototype class defineScope(modelFrom.prototype, params.through || modelTo, relationName, function () { var filter = {}; @@ -420,10 +547,15 @@ RelationDefinition.hasMany = function hasMany(modelFrom, modelTo, params) { definition.applyScope(this, filter); - if (params.through) { + if (params.through && params.polymorphic && params.invert) { + filter.where[discriminator] = modelTo.modelName; // overwrite + filter.collect = params.polymorphic; + filter.include = filter.collect; + } else if (params.through) { filter.collect = i8n.camelize(modelTo.modelName, true); filter.include = filter.collect; } + return filter; }, scopeMethods); @@ -457,6 +589,7 @@ function scopeMethod(definition, methodName) { */ HasMany.prototype.findById = function (fkId, cb) { var modelTo = this.definition.modelTo; + var modelFrom = this.definition.modelFrom; var fk = this.definition.keyTo; var pk = this.definition.keyFrom; var modelInstance = this.modelInstance; @@ -486,7 +619,7 @@ HasMany.prototype.findById = function (fkId, cb) { if (inst[fk] && inst[fk].toString() === modelInstance[pk].toString()) { cb(null, inst); } else { - err = new Error('Key mismatch: ' + this.definition.modelFrom.modelName + '.' + pk + err = new Error('Key mismatch: ' + modelFrom.modelName + '.' + pk + ': ' + modelInstance[pk] + ', ' + modelTo.modelName + '.' + fk + ': ' + inst[fk]); err.statusCode = 400; @@ -552,6 +685,21 @@ HasMany.prototype.destroyById = function (fkId, cb) { }); }; +var throughKeys = function(definition) { + var modelThrough = definition.modelThrough; + var pk2 = definition.modelTo.definition.idName(); + + if (definition.discriminator) { // polymorphic + var fk1 = definition.keyTo; + var fk2 = definition.keyThrough; + } else { + var fk1 = findBelongsTo(modelThrough, definition.modelFrom, + definition.keyFrom); + var fk2 = findBelongsTo(modelThrough, definition.modelTo, pk2); + } + return [fk1, fk2]; +} + /** * Find a related item by foreign key * @param {*} fkId The foreign key value @@ -626,7 +774,7 @@ HasManyThrough.prototype.create = function create(data, done) { var definition = this.definition; var modelTo = definition.modelTo; var modelThrough = definition.modelThrough; - + if (typeof data === 'function' && !done) { done = data; data = {}; @@ -641,9 +789,11 @@ HasManyThrough.prototype.create = function create(data, done) { } // The primary key for the target model var pk2 = definition.modelTo.definition.idName(); - var fk1 = findBelongsTo(modelThrough, definition.modelFrom, - definition.keyFrom); - var fk2 = findBelongsTo(modelThrough, definition.modelTo, pk2); + + var keys = throughKeys(definition); + var fk1 = keys[0]; + var fk2 = keys[1]; + var d = {}; d[fk1] = modelInstance[definition.keyFrom]; d[fk2] = to[pk2]; @@ -665,6 +815,8 @@ HasManyThrough.prototype.create = function create(data, done) { }); }; + + /** * Add the target model instance to the 'hasMany' relation * @param {Object|ID} acInst The actual instance or id value @@ -677,14 +829,13 @@ HasManyThrough.prototype.add = function (acInst, done) { 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); + var keys = throughKeys(definition); + var fk1 = keys[0]; + var fk2 = keys[1]; query[fk1] = this.modelInstance[pk1]; query[fk2] = (acInst instanceof definition.modelTo) ? acInst[pk2] : acInst; @@ -695,6 +846,7 @@ HasManyThrough.prototype.add = function (acInst, done) { data[fk1] = this.modelInstance[pk1]; data[fk2] = (acInst instanceof definition.modelTo) ? acInst[pk2] : acInst; + definition.applyProperties(this.modelInstance, data); // Create an instance of the through model @@ -717,25 +869,23 @@ HasManyThrough.prototype.exists = function (acInst, done) { 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); + + var keys = throughKeys(definition); + var fk1 = keys[0]; + var fk2 = keys[1]; 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) { + + var filter = { where: query }; + + definition.applyScope(this.modelInstance, filter); + + modelThrough.count(filter.where, function(err, ac) { done(err, ac > 0); }); }; @@ -752,13 +902,12 @@ HasManyThrough.prototype.remove = function (acInst, done) { 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); + + var keys = throughKeys(definition); + var fk1 = keys[0]; + var fk2 = keys[1]; query[fk1] = this.modelInstance[pk1]; query[fk2] = (acInst instanceof definition.modelTo) ? acInst[pk2] : acInst; @@ -766,7 +915,7 @@ HasManyThrough.prototype.remove = function (acInst, done) { var filter = { where: query }; definition.applyScope(this.modelInstance, filter); - + modelThrough.deleteAll(filter.where, function (err) { if (!err) { self.removeFromCache(query[fk2]); @@ -801,8 +950,9 @@ HasManyThrough.prototype.remove = function (acInst, done) { * */ RelationDefinition.belongsTo = function (modelFrom, modelTo, params) { + var discriminator, polymorphic; params = params || {}; - if ('string' === typeof modelTo) { + if ('string' === typeof modelTo && !params.polymorphic) { params.as = modelTo; if (params.model) { modelTo = params.model; @@ -812,9 +962,36 @@ 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 idName, relationName, fk; + if (params.polymorphic) { + if (params.polymorphic === true) { + // modelTo arg will be the name of the polymorphic relation (string) + polymorphic = polymorphicParams(modelTo); + } else { + polymorphic = polymorphicParams(params.polymorphic); + } + + modelTo = null; // will lookup dynamically + + idName = params.idName || 'id'; + relationName = params.as || polymorphic.as; + fk = polymorphic.foreignKey; + discriminator = polymorphic.discriminator; + + if (typeof polymorphic.idType === 'string') { // explicit key type + modelFrom.dataSource.defineProperty(modelFrom.modelName, fk, { type: polymorphic.idType, index: true }); + } else { // try to use the same foreign key type as modelFrom + modelFrom.dataSource.defineForeignKey(modelFrom.modelName, fk, modelFrom.modelName); + } + + modelFrom.dataSource.defineProperty(modelFrom.modelName, discriminator, { type: 'string', index: true }); + } else { + idName = modelFrom.dataSource.idName(modelTo.modelName) || 'id'; + relationName = params.as || i8n.camelize(modelTo.modelName, true); + fk = params.foreignKey || relationName + 'Id'; + + modelFrom.dataSource.defineForeignKey(modelFrom.modelName, fk, modelTo.modelName); + } var relationDef = modelFrom.relations[relationName] = new RelationDefinition({ name: relationName, @@ -822,14 +999,13 @@ RelationDefinition.belongsTo = function (modelFrom, modelTo, params) { modelFrom: modelFrom, keyFrom: fk, keyTo: idName, + discriminator: discriminator, modelTo: modelTo, properties: params.properties, scope: params.scope, options: params.options }); - - modelFrom.dataSource.defineForeignKey(modelFrom.modelName, fk, modelTo.modelName); - + // Define a property for the scope so that we have 'this' for the scoped methods Object.defineProperty(modelFrom.prototype, relationName, { enumerable: true, @@ -839,7 +1015,9 @@ RelationDefinition.belongsTo = function (modelFrom, modelTo, params) { var relationMethod = relation.related.bind(relation); relationMethod.create = relation.create.bind(relation); relationMethod.build = relation.build.bind(relation); - relationMethod._targetClass = relationDef.modelTo.modelName; + if (relationDef.modelTo) { + relationMethod._targetClass = relationDef.modelTo.modelName; + } return relationMethod; } }); @@ -867,7 +1045,7 @@ BelongsTo.prototype.create = function(targetModelData, cb) { } this.definition.applyProperties(modelInstance, targetModelData || {}); - + modelTo.create(targetModelData, function(err, targetModel) { if(!err) { modelInstance[fk] = targetModel[pk]; @@ -898,7 +1076,9 @@ BelongsTo.prototype.build = function(targetModelData) { */ BelongsTo.prototype.related = function (refresh, params) { var self = this; + var modelFrom = this.definition.modelFrom; var modelTo = this.definition.modelTo; + var discriminator = this.definition.discriminator; var pk = this.definition.keyTo; var fk = this.definition.keyFrom; var modelInstance = this.modelInstance; @@ -909,15 +1089,35 @@ BelongsTo.prototype.related = function (refresh, params) { } else if (arguments.length > 2) { throw new Error('Method can\'t be called with more than two arguments'); } - + var cachedValue; if (!refresh) { cachedValue = self.getCache(); } if (params instanceof ModelBaseClass) { // acts as setter + modelTo = params.constructor; modelInstance[fk] = params[pk]; + if (discriminator) modelInstance[discriminator] = params.constructor.modelName; + + var data = {}; + this.definition.applyProperties(params, data); + modelInstance.setAttributes(data); + self.resetCache(params); } else if (typeof params === 'function') { // acts as async getter + + if (discriminator && !modelTo) { + var modelToName = modelInstance[discriminator]; + if (typeof modelToName !== 'string') { + throw new Error('Polymorphic model not found: `' + discriminator + '` not set'); + } + modelToName = modelToName.toLowerCase(); + modelTo = lookupModel(modelFrom.dataSource.modelBuilder.models, modelToName); + if (!modelTo) { + throw new Error('Polymorphic model not found: `' + modelToName + '`'); + } + } + var cb = params; if (cachedValue === undefined) { var query = {where: {}}; @@ -989,24 +1189,40 @@ RelationDefinition.hasAndBelongsToMany = function hasAndBelongsToMany(modelFrom, if (params.model) { modelTo = params.model; } else { - modelTo = lookupModel(models, i8n.singularize(modelTo)) || - modelTo; + modelTo = lookupModel(models, i8n.singularize(modelTo)) || modelTo; } if (typeof modelTo === 'string') { throw new Error('Could not find "' + modelTo + '" relation for ' + modelFrom.modelName); } } - + if (!params.through) { + if (params.polymorphic) throw new Error('Polymorphic relations need a through model'); var name1 = modelFrom.modelName + modelTo.modelName; var name2 = modelTo.modelName + modelFrom.modelName; params.through = lookupModel(models, name1) || lookupModel(models, name2) || modelFrom.dataSource.define(name1); } - params.through.belongsTo(modelFrom); + + var options = {as: params.as, through: params.through}; + options.properties = params.properties; + options.scope = params.scope; + + if (params.polymorphic) { + var polymorphic = polymorphicParams(params.polymorphic); + options.polymorphic = polymorphic; // pass through + var accessor = params.through.prototype[polymorphic.as]; + if (typeof accessor !== 'function') { // declare once + // use the name of the polymorphic rel, not modelTo + params.through.belongsTo(polymorphic.as, { polymorphic: true }); + } + } else { + params.through.belongsTo(modelFrom); + } + params.through.belongsTo(modelTo); - - this.hasMany(modelFrom, modelTo, {as: params.as, through: params.through}); + + this.hasMany(modelFrom, modelTo, options); }; @@ -1041,6 +1257,16 @@ 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 discriminator; + + if (params.polymorphic) { + var polymorphic = polymorphicParams(params.polymorphic); + fk = polymorphic.foreignKey; + discriminator = polymorphic.discriminator; + if (!params.through) { + modelTo.dataSource.defineProperty(modelTo.modelName, discriminator, { type: 'string', index: true }); + } + } var relationDef = modelFrom.relations[relationName] = new RelationDefinition({ name: relationName, @@ -1048,6 +1274,7 @@ RelationDefinition.hasOne = function (modelFrom, modelTo, params) { modelFrom: modelFrom, keyFrom: pk, keyTo: fk, + discriminator: discriminator, modelTo: modelTo, properties: params.properties, options: params.options @@ -1238,3 +1465,789 @@ HasOne.prototype.related = function (refresh, params) { self.resetCache(); } }; + +RelationDefinition.embedsMany = function embedsMany(modelFrom, modelTo, params) { + var thisClassName = modelFrom.modelName; + params = params || {}; + if (typeof modelTo === 'string') { + params.as = modelTo; + if (params.model) { + modelTo = params.model; + } else { + var modelToName = i8n.singularize(modelTo).toLowerCase(); + modelTo = lookupModel(modelFrom.dataSource.modelBuilder.models, modelToName); + } + } + + var accessorName = params.as || (i8n.camelize(modelTo.modelName, true) + 'List'); + var relationName = params.property || i8n.camelize(modelTo.pluralModelName, true); + var fk = modelTo.dataSource.idName(modelTo.modelName) || 'id'; + var idName = modelFrom.dataSource.idName(modelFrom.modelName) || 'id'; + + var definition = modelFrom.relations[accessorName] = new RelationDefinition({ + accessor: accessorName, + name: relationName, + type: RelationTypes.embedsMany, + modelFrom: modelFrom, + keyFrom: idName, + keyTo: fk, + modelTo: modelTo, + multiple: true, + properties: params.properties, + scope: params.scope, + options: params.options, + embed: true + }); + + modelFrom.dataSource.defineProperty(modelFrom.modelName, relationName, { + type: [modelTo], default: function() { return []; } + }); + + // unique id is required + modelTo.validatesPresenceOf(idName); + + if (!params.polymorphic) { + modelFrom.validate(relationName, function(err) { + var embeddedList = this[relationName] || []; + var ids = embeddedList.map(function(m) { return m[idName]; }); + var uniqueIds = ids.filter(function(id, pos) { + return ids.indexOf(id) === pos; + }); + if (ids.length !== uniqueIds.length) { + this.errors.add(relationName, 'Contains duplicate `' + idName + '`', 'uniqueness'); + err(false); + } + }, { code: 'uniqueness' }) + } + + // validate all embedded items + if (definition.options.validate) { + modelFrom.validate(relationName, function(err) { + var self = this; + var embeddedList = this[relationName] || []; + var hasErrors = false; + embeddedList.forEach(function(item) { + if (item instanceof modelTo) { + if (!item.isValid()) { + hasErrors = true; + var id = item[idName] || '(blank)'; + var first = Object.keys(item.errors)[0]; + var msg = 'contains invalid item: `' + id + '`'; + msg += ' (' + first + ' ' + item.errors[first] + ')'; + self.errors.add(relationName, msg, 'invalid'); + } + } else { + hasErrors = true; + self.errors.add(relationName, 'Contains invalid item', 'invalid'); + } + }); + if (hasErrors) err(false); + }); + } + + var scopeMethods = { + findById: scopeMethod(definition, 'findById'), + destroy: scopeMethod(definition, 'destroyById'), + updateById: scopeMethod(definition, 'updateById'), + exists: scopeMethod(definition, 'exists'), + add: scopeMethod(definition, 'add'), + remove: scopeMethod(definition, 'remove'), + get: scopeMethod(definition, 'get'), + set: scopeMethod(definition, 'set'), + unset: scopeMethod(definition, 'unset'), + at: scopeMethod(definition, 'at') + }; + + var findByIdFunc = scopeMethods.findById; + modelFrom.prototype['__findById__' + accessorName] = findByIdFunc; + + var destroyByIdFunc = scopeMethods.destroy; + modelFrom.prototype['__destroyById__' + accessorName] = destroyByIdFunc; + + var updateByIdFunc = scopeMethods.updateById; + modelFrom.prototype['__updateById__' + accessorName] = updateByIdFunc; + + var addFunc = scopeMethods.add; + modelFrom.prototype['__link__' + accessorName] = addFunc; + + var removeFunc = scopeMethods.remove; + modelFrom.prototype['__unlink__' + accessorName] = removeFunc; + + scopeMethods.create = scopeMethod(definition, 'create'); + scopeMethods.build = scopeMethod(definition, 'build'); + + scopeMethods.related = scopeMethod(definition, 'related'); // bound to definition + + var customMethods = extendScopeMethods(definition, scopeMethods, params.scopeMethods); + + for (var i = 0; i < customMethods.length; i++) { + var methodName = customMethods[i]; + var method = scopeMethods[methodName]; + if (typeof method === 'function' && method.shared === true) { + modelFrom.prototype['__' + methodName + '__' + accessorName] = method; + } + }; + + // Mix the property and scoped methods into the prototype class + var scopeDefinition = defineScope(modelFrom.prototype, modelTo, accessorName, function () { + return {}; + }, scopeMethods); + + scopeDefinition.related = scopeMethods.related; +}; + +EmbedsMany.prototype.related = function(receiver, scopeParams, condOrRefresh, cb) { + var modelTo = this.definition.modelTo; + var relationName = this.definition.name; + var modelInstance = this.modelInstance; + var self = receiver; + + var actualCond = {}; + var actualRefresh = false; + if (arguments.length === 3) { + cb = condOrRefresh; + } else if (arguments.length === 4) { + if (typeof condOrRefresh === 'boolean') { + actualRefresh = condOrRefresh; + } else { + actualCond = condOrRefresh; + actualRefresh = true; + } + } else { + throw new Error('Method can be only called with one or two arguments'); + } + + var embeddedList = self[relationName] || []; + + this.definition.applyScope(modelInstance, actualCond); + + var params = mergeQuery(actualCond, scopeParams); + + if (params.where) { + embeddedList = embeddedList ? embeddedList.filter(applyFilter(params)) : embeddedList; + } + + var returnRelated = function(list) { + if (params.include) { + modelTo.include(list, params.include, cb); + } else { + process.nextTick(function() { cb(null, list); }); + } + }; + + returnRelated(embeddedList); +}; + +EmbedsMany.prototype.findById = function (fkId, cb) { + var pk = this.definition.keyFrom; + var modelTo = this.definition.modelTo; + var relationName = this.definition.name; + var modelInstance = this.modelInstance; + + var embeddedList = modelInstance[relationName] || []; + + var find = function(id) { + for (var i = 0; i < embeddedList.length; i++) { + var item = embeddedList[i]; + if (item[pk].toString() === id) return item; + } + return null; + }; + + var item = find(fkId.toString()); // in case of explicit id + item = (item instanceof modelTo) ? item : null; + + if (typeof cb === 'function') { + process.nextTick(function() { + cb(null, item); + }); + }; + + return item; // sync +}; + +EmbedsMany.prototype.exists = function (fkId, cb) { + var modelTo = this.definition.modelTo; + var inst = this.findById(fkId, function (err, inst) { + if (cb) cb(err, inst instanceof modelTo); + }); + return inst instanceof modelTo; // sync +}; + +EmbedsMany.prototype.updateById = function (fkId, data, cb) { + if (typeof data === 'function') { + cb = data; + data = {}; + } + + var modelTo = this.definition.modelTo; + var relationName = this.definition.name; + var modelInstance = this.modelInstance; + + var embeddedList = modelInstance[relationName] || []; + + var inst = this.findById(fkId); + + if (inst instanceof modelTo) { + if (typeof data === 'object') { + for (var key in data) { + inst[key] = data[key]; + } + } + var err = inst.isValid() ? null : new ValidationError(inst); + if (err && typeof cb === 'function') { + return process.nextTick(function() { + cb(err, inst); + }); + } + + if (typeof cb === 'function') { + modelInstance.updateAttribute(relationName, + embeddedList, function(err) { + cb(err, inst); + }); + } + } else if (typeof cb === 'function') { + process.nextTick(function() { + cb(null, null); // not found + }); + } + return inst; // sync +}; + +EmbedsMany.prototype.destroyById = function (fkId, cb) { + var modelTo = this.definition.modelTo; + var relationName = this.definition.name; + var modelInstance = this.modelInstance; + + var embeddedList = modelInstance[relationName] || []; + + var inst = (fkId instanceof modelTo) ? fkId : this.findById(fkId); + + if (inst instanceof modelTo) { + var index = embeddedList.indexOf(inst); + if (index > -1) embeddedList.splice(index, 1); + if (typeof cb === 'function') { + modelInstance.updateAttribute(relationName, + embeddedList, function(err) { + cb(err); + }); + } + } else if (typeof cb === 'function') { + process.nextTick(cb); // not found + } + return inst; // sync +}; + +EmbedsMany.prototype.get = EmbedsMany.prototype.findById; +EmbedsMany.prototype.set = EmbedsMany.prototype.updateById; +EmbedsMany.prototype.unset = EmbedsMany.prototype.destroyById; + +EmbedsMany.prototype.at = function (index, cb) { + var modelTo = this.definition.modelTo; + var relationName = this.definition.name; + var modelInstance = this.modelInstance; + + var embeddedList = modelInstance[relationName] || []; + + var item = embeddedList[parseInt(index)]; + item = (item instanceof modelTo) ? item : null; + + if (typeof cb === 'function') { + process.nextTick(function() { + cb(null, item); + }); + }; + + return item; // sync +}; + +EmbedsMany.prototype.create = function (targetModelData, cb) { + var pk = this.definition.keyFrom; + var modelTo = this.definition.modelTo; + var relationName = this.definition.name; + var modelInstance = this.modelInstance; + var autoId = this.definition.options.autoId !== false; + + if (typeof targetModelData === 'function' && !cb) { + cb = targetModelData; + targetModelData = {}; + } + targetModelData = targetModelData || {}; + + var embeddedList = modelInstance[relationName] || []; + + var inst = this.build(targetModelData); + + var err = inst.isValid() ? null : new ValidationError(inst); + + if (err) { + return process.nextTick(function() { + cb(err); + }); + } + + modelInstance.updateAttribute(relationName, + embeddedList, function(err, modelInst) { + cb(err, err ? null : inst); + }); +}; + +EmbedsMany.prototype.build = function(targetModelData) { + var pk = this.definition.keyFrom; + var modelTo = this.definition.modelTo; + var relationName = this.definition.name; + var modelInstance = this.modelInstance; + var autoId = this.definition.options.autoId !== false; + + var embeddedList = modelInstance[relationName] || []; + + targetModelData = targetModelData || {}; + + if (typeof targetModelData[pk] !== 'number' && autoId) { + var ids = embeddedList.map(function(m) { + return (typeof m[pk] === 'number' ? m[pk] : 0); + }); + if (ids.length > 0) { + targetModelData[pk] = Math.max.apply(null, ids) + 1; + } else { + targetModelData[pk] = 1; + } + } + + this.definition.applyProperties(this.modelInstance, targetModelData); + + var inst = new modelTo(targetModelData); + + if (this.definition.options.prepend) { + embeddedList.unshift(inst); + } else { + embeddedList.push(inst); + } + + return inst; +}; + +/** + * Add the target model instance to the 'embedsMany' relation + * @param {Object|ID} acInst The actual instance or id value + */ +EmbedsMany.prototype.add = function (acInst, data, cb) { + if (typeof data === 'function') { + cb = data; + data = {}; + } + + var self = this; + var definition = this.definition; + var modelTo = this.definition.modelTo; + var modelInstance = this.modelInstance; + + var options = definition.options; + var belongsTo = options.belongsTo && modelTo.relations[options.belongsTo]; + + if (!belongsTo) { + throw new Error('Invalid reference: ' + options.belongsTo || '(none)'); + } + + var fk2 = belongsTo.keyTo; + var pk2 = belongsTo.modelTo.definition.idName() || 'id'; + + var query = {}; + + query[fk2] = (acInst instanceof belongsTo.modelTo) ? acInst[pk2] : acInst; + + var filter = { where: query }; + + belongsTo.applyScope(modelInstance, filter); + + belongsTo.modelTo.findOne(filter, function(err, ref) { + if (ref instanceof belongsTo.modelTo) { + var inst = self.build(data || {}); + inst[options.belongsTo](ref); + modelInstance.save(function(err) { + cb(err, err ? null : inst); + }); + } else { + cb(null, null); + } + }); +}; + +/** + * Remove the target model instance from the 'embedsMany' relation + * @param {Object|ID) acInst The actual instance or id value + */ +EmbedsMany.prototype.remove = function (acInst, cb) { + var self = this; + var definition = this.definition; + var modelTo = this.definition.modelTo; + var modelInstance = this.modelInstance; + + var options = definition.options; + var belongsTo = options.belongsTo && modelTo.relations[options.belongsTo]; + + if (!belongsTo) { + throw new Error('Invalid reference: ' + options.belongsTo || '(none)'); + } + + var fk2 = belongsTo.keyTo; + var pk2 = belongsTo.modelTo.definition.idName() || 'id'; + + var query = {}; + + query[fk2] = (acInst instanceof belongsTo.modelTo) ? acInst[pk2] : acInst; + + var filter = { where: query }; + + belongsTo.applyScope(modelInstance, filter); + + modelInstance[definition.accessor](filter, function(err, items) { + if (err) return cb(err); + + items.forEach(function(item) { + self.unset(item); + }); + + modelInstance.save(function(err) { + cb(err); + }); + }); +}; + +RelationDefinition.referencesMany = function referencesMany(modelFrom, modelTo, params) { + var thisClassName = modelFrom.modelName; + params = params || {}; + if (typeof modelTo === 'string') { + params.as = modelTo; + if (params.model) { + modelTo = params.model; + } else { + var modelToName = i8n.singularize(modelTo).toLowerCase(); + modelTo = lookupModel(modelFrom.dataSource.modelBuilder.models, modelToName); + } + } + + var relationName = params.as || i8n.camelize(modelTo.pluralModelName, true); + var fk = params.foreignKey || i8n.camelize(modelTo.modelName + '_ids', true); + var idName = modelTo.dataSource.idName(modelTo.modelName) || 'id'; + var idType = modelTo.definition.properties[idName].type; + + var definition = modelFrom.relations[relationName] = new RelationDefinition({ + name: relationName, + type: RelationTypes.referencesMany, + modelFrom: modelFrom, + keyFrom: fk, + keyTo: idName, + modelTo: modelTo, + multiple: true, + properties: params.properties, + scope: params.scope, + options: params.options + }); + + modelFrom.dataSource.defineProperty(modelFrom.modelName, fk, { + type: [idType], default: function() { return []; } + }); + + modelFrom.validate(relationName, function(err) { + var ids = this[fk] || []; + var uniqueIds = ids.filter(function(id, pos) { + return ids.indexOf(id) === pos; + }); + if (ids.length !== uniqueIds.length) { + var msg = 'Contains duplicate `' + modelTo.modelName + '` instance'; + this.errors.add(relationName, msg, 'uniqueness'); + err(false); + } + }, { code: 'uniqueness' }) + + var scopeMethods = { + findById: scopeMethod(definition, 'findById'), + destroy: scopeMethod(definition, 'destroyById'), + updateById: scopeMethod(definition, 'updateById'), + exists: scopeMethod(definition, 'exists'), + add: scopeMethod(definition, 'add'), + remove: scopeMethod(definition, 'remove'), + at: scopeMethod(definition, 'at') + }; + + var findByIdFunc = scopeMethods.findById; + modelFrom.prototype['__findById__' + relationName] = findByIdFunc; + + var destroyByIdFunc = scopeMethods.destroy; + modelFrom.prototype['__destroyById__' + relationName] = destroyByIdFunc; + + var updateByIdFunc = scopeMethods.updateById; + modelFrom.prototype['__updateById__' + relationName] = updateByIdFunc; + + var addFunc = scopeMethods.add; + modelFrom.prototype['__link__' + relationName] = addFunc; + + var removeFunc = scopeMethods.remove; + modelFrom.prototype['__unlink__' + relationName] = removeFunc; + + scopeMethods.create = scopeMethod(definition, 'create'); + scopeMethods.build = scopeMethod(definition, 'build'); + + scopeMethods.related = scopeMethod(definition, 'related'); + + var customMethods = extendScopeMethods(definition, scopeMethods, params.scopeMethods); + + for (var i = 0; i < customMethods.length; i++) { + var methodName = customMethods[i]; + var method = scopeMethods[methodName]; + if (typeof method === 'function' && method.shared === true) { + modelFrom.prototype['__' + methodName + '__' + relationName] = method; + } + }; + + // Mix the property and scoped methods into the prototype class + var scopeDefinition = defineScope(modelFrom.prototype, modelTo, relationName, function () { + return {}; + }, scopeMethods); + + scopeDefinition.related = scopeMethods.related; // bound to definition +}; + +ReferencesMany.prototype.related = function(receiver, scopeParams, condOrRefresh, cb) { + var fk = this.definition.keyFrom; + var modelTo = this.definition.modelTo; + var relationName = this.definition.name; + var modelInstance = this.modelInstance; + var self = receiver; + + var actualCond = {}; + var actualRefresh = false; + if (arguments.length === 3) { + cb = condOrRefresh; + } else if (arguments.length === 4) { + if (typeof condOrRefresh === 'boolean') { + actualRefresh = condOrRefresh; + } else { + actualCond = condOrRefresh; + actualRefresh = true; + } + } else { + throw new Error('Method can be only called with one or two arguments'); + } + + var ids = self[fk] || []; + + this.definition.applyScope(modelInstance, actualCond); + + var params = mergeQuery(actualCond, scopeParams); + + modelTo.findByIds(ids, params, cb); +}; + +ReferencesMany.prototype.findById = function (fkId, cb) { + var modelTo = this.definition.modelTo; + var modelFrom = this.definition.modelFrom; + var relationName = this.definition.name; + var modelInstance = this.modelInstance; + + var modelTo = this.definition.modelTo; + var pk = this.definition.keyTo; + var fk = this.definition.keyFrom; + + if (typeof fkId === 'object') { + fkId = fkId.toString(); // mongodb + } + + var ids = [fkId]; + + var filter = {}; + + this.definition.applyScope(modelInstance, filter); + + modelTo.findByIds(ids, filter, function (err, instances) { + if (err) { + return cb(err); + } + + var inst = instances[0]; + if (!inst) { + err = new Error('No instance with id ' + fkId + ' found for ' + modelTo.modelName); + err.statusCode = 404; + return cb(err); + } + + var currentIds = ids.map(function(id) { return id.toString(); }); + var id = (inst[pk] || '').toString(); // mongodb + + // Check if the foreign key is amongst the ids + if (currentIds.indexOf(id) > -1) { + cb(null, inst); + } else { + err = new Error('Key mismatch: ' + modelFrom.modelName + '.' + fk + + ': ' + modelInstance[fk] + + ', ' + modelTo.modelName + '.' + pk + ': ' + inst[pk]); + err.statusCode = 400; + cb(err); + } + }); +}; + +ReferencesMany.prototype.exists = function (fkId, cb) { + var fk = this.definition.keyFrom; + var ids = this.modelInstance[fk] || []; + var currentIds = ids.map(function(id) { return id.toString(); }); + var fkId = (fkId || '').toString(); // mongodb + process.nextTick(function() { cb(null, currentIds.indexOf(fkId) > -1) }); +}; + +ReferencesMany.prototype.updateById = function (fkId, data, cb) { + if (typeof data === 'function') { + cb = data; + data = {}; + } + + this.findById(fkId, function(err, inst) { + if (err) return cb(err); + inst.updateAttributes(data, cb); + }); +}; + +ReferencesMany.prototype.destroyById = function (fkId, cb) { + var self = this; + this.findById(fkId, function(err, inst) { + if (err) return cb(err); + self.remove(inst, function(err, ids) { + inst.destroy(cb); + }); + }); +}; + +ReferencesMany.prototype.at = function (index, cb) { + var fk = this.definition.keyFrom; + var ids = this.modelInstance[fk] || []; + this.findById(ids[index], cb); +}; + +ReferencesMany.prototype.create = function (targetModelData, cb) { + var definition = this.definition; + var modelTo = this.definition.modelTo; + var relationName = this.definition.name; + var modelInstance = this.modelInstance; + + var pk = this.definition.keyTo; + var fk = this.definition.keyFrom; + + if (typeof targetModelData === 'function' && !cb) { + cb = targetModelData; + targetModelData = {}; + } + targetModelData = targetModelData || {}; + + var ids = modelInstance[fk] || []; + + var inst = this.build(targetModelData); + + inst.save(function(err, inst) { + if (err) return cb(err, inst); + + var id = inst[pk]; + + if (typeof id === 'object') { + id = id.toString(); // mongodb + } + + if (definition.options.prepend) { + ids.unshift(id); + } else { + ids.push(id); + } + + modelInstance.updateAttribute(fk, + ids, function(err, modelInst) { + cb(err, inst); + }); + }); +}; + +ReferencesMany.prototype.build = function(targetModelData) { + var modelTo = this.definition.modelTo; + targetModelData = targetModelData || {}; + + this.definition.applyProperties(this.modelInstance, targetModelData); + + return new modelTo(targetModelData); +}; + +/** + * Add the target model instance to the 'embedsMany' relation + * @param {Object|ID} acInst The actual instance or id value + */ +ReferencesMany.prototype.add = function (acInst, cb) { + var self = this; + var definition = this.definition; + var modelTo = this.definition.modelTo; + var modelInstance = this.modelInstance; + + var pk = this.definition.keyTo; + var fk = this.definition.keyFrom; + + var insert = function(inst, done) { + var id = inst[pk]; + + if (typeof id === 'object') { + id = id.toString(); // mongodb + } + + var ids = modelInstance[fk] || []; + + if (definition.options.prepend) { + ids.unshift(id); + } else { + ids.push(id); + } + + modelInstance.updateAttribute(fk, ids, function(err) { + done(err, err ? null : inst); + }); + }; + + if (acInst instanceof modelTo) { + insert(acInst, cb); + } else { + var filter = { where: {} }; + filter.where[pk] = acInst; + + definition.applyScope(modelInstance, filter); + + modelTo.findOne(filter, function (err, inst) { + if (err || !inst) return cb(err, null); + insert(inst, cb); + }); + } +}; + +/** + * Remove the target model instance from the 'embedsMany' relation + * @param {Object|ID) acInst The actual instance or id value + */ +ReferencesMany.prototype.remove = function (acInst, cb) { + var definition = this.definition; + var modelInstance = this.modelInstance; + + var pk = this.definition.keyTo; + var fk = this.definition.keyFrom; + + var ids = modelInstance[fk] || []; + + var currentIds = ids.map(function(id) { return id.toString(); }); + + var id = (acInst instanceof definition.modelTo) ? acInst[pk] : acInst; + id = id.toString(); + + var index = currentIds.indexOf(id); + if (index > -1) { + ids.splice(index, 1); + modelInstance.updateAttribute(fk, ids, function(err, inst) { + cb(err, inst[fk] || []); + }); + } else { + process.nextTick(function() { cb(null, ids); }); + } +}; \ No newline at end of file diff --git a/lib/relations.js b/lib/relations.js index c0985f2f..d3e735ff 100644 --- a/lib/relations.js +++ b/lib/relations.js @@ -161,3 +161,11 @@ RelationMixin.hasAndBelongsToMany = function hasAndBelongsToMany(modelTo, params RelationMixin.hasOne = function hasMany(modelTo, params) { RelationDefinition.hasOne(this, modelTo, params); }; + +RelationMixin.referencesMany = function hasMany(modelTo, params) { + RelationDefinition.referencesMany(this, modelTo, params); +}; + +RelationMixin.embedsMany = function hasMany(modelTo, params) { + RelationDefinition.embedsMany(this, modelTo, params); +}; diff --git a/lib/scope.js b/lib/scope.js index c1c90eba..129e0b9f 100644 --- a/lib/scope.js +++ b/lib/scope.js @@ -18,7 +18,7 @@ function ScopeDefinition(definition) { ScopeDefinition.prototype.related = function(receiver, scopeParams, condOrRefresh, cb) { var name = this.name; var self = receiver; - + var actualCond = {}; var actualRefresh = false; var saveOnCache = true; @@ -224,6 +224,8 @@ function defineScope(cls, targetClass, name, params, methods) { var where = (this._scope && this._scope.where) || {}; targetClass.destroyAll(where, cb); } + + return definition; } /*! diff --git a/package.json b/package.json index 30921e06..bcdbda64 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "loopback-datasource-juggler", - "version": "2.1.1", + "version": "2.2.0", "description": "LoopBack DataSoure Juggler", "keywords": [ "StrongLoop", diff --git a/test/basic-querying.test.js b/test/basic-querying.test.js index 2a235def..dd26928a 100644 --- a/test/basic-querying.test.js +++ b/test/basic-querying.test.js @@ -50,6 +50,45 @@ describe('basic-querying', function () { }); }); + + describe('findById', function () { + + before(function(done) { + var people = [ + { id: 1, name: 'a', vip: true }, + { id: 2, name: 'b' }, + { id: 3, name: 'c' }, + { id: 4, name: 'd', vip: true }, + { id: 5, name: 'e' }, + { id: 6, name: 'f' } + ]; + User.destroyAll(function() { + User.create(people, done); + }); + }); + + it('should query by ids', function (done) { + User.findByIds([3, 2, 1], function (err, users) { + should.exist(users); + should.not.exist(err); + var names = users.map(function(u) { return u.name; }); + names.should.eql(['c', 'b', 'a']); + done(); + }); + }); + + it('should query by ids and condition', function (done) { + User.findByIds([4, 3, 2, 1], + { where: { vip: true } }, function (err, users) { + should.exist(users); + should.not.exist(err); + var names = users.map(function(u) { return u.name; }); + names.should.eql(['d', 'a']); + done(); + }); + }); + + }); describe('find', function () { diff --git a/test/relations.test.js b/test/relations.test.js index f091486d..bb054a7a 100644 --- a/test/relations.test.js +++ b/test/relations.test.js @@ -3,6 +3,9 @@ var should = require('./init.js'); var db, Book, Chapter, Author, Reader; var Category, Product; +var Picture, PictureLink; +var Person, Address; +var Link; describe('relations', function () { @@ -526,6 +529,481 @@ describe('relations', function () { }); }); + + describe('polymorphic hasOne', function () { + before(function (done) { + db = getSchema(); + Picture = db.define('Picture', {name: String}); + Author = db.define('Author', {name: String}); + Reader = db.define('Reader', {name: String}); + + db.automigrate(function () { + Picture.destroyAll(function () { + Author.destroyAll(function () { + Reader.destroyAll(done); + }); + }); + }); + }); + + it('can be declared', function (done) { + Author.hasOne(Picture, { as: 'avatar', polymorphic: 'imageable' }); + Reader.hasOne(Picture, { as: 'mugshot', polymorphic: 'imageable' }); + Picture.belongsTo('imageable', { polymorphic: true }); + db.automigrate(done); + }); + + it('should create polymorphic relation - author', function (done) { + Author.create({name: 'Author 1' }, function (err, author) { + author.avatar.create({ name: 'Avatar' }, function (err, p) { + should.not.exist(err); + should.exist(p); + p.imageableId.should.equal(author.id); + p.imageableType.should.equal('Author'); + done(); + }); + }); + }); + + it('should create polymorphic relation - reader', function (done) { + Reader.create({name: 'Reader 1' }, function (err, reader) { + reader.mugshot.create({ name: 'Mugshot' }, function (err, p) { + should.not.exist(err); + should.exist(p); + p.imageableId.should.equal(reader.id); + p.imageableType.should.equal('Reader'); + done(); + }); + }); + }); + + it('should find polymorphic relation - author', function (done) { + Author.findOne(function (err, author) { + author.avatar(function (err, p) { + should.not.exist(err); + p.name.should.equal('Avatar'); + p.imageableId.should.eql(author.id); + p.imageableType.should.equal('Author'); + done(); + }); + }); + }); + + it('should find polymorphic relation - reader', function (done) { + Reader.findOne(function (err, reader) { + reader.mugshot(function (err, p) { + should.not.exist(err); + p.name.should.equal('Mugshot'); + p.imageableId.should.eql(reader.id); + p.imageableType.should.equal('Reader'); + done(); + }); + }); + }); + + it('should find inverse polymorphic relation - author', function (done) { + Picture.findOne({ where: { name: 'Avatar' } }, function (err, p) { + p.imageable(function (err, imageable) { + should.not.exist(err); + imageable.should.be.instanceof(Author); + imageable.name.should.equal('Author 1'); + done(); + }); + }); + }); + + it('should find inverse polymorphic relation - reader', function (done) { + Picture.findOne({ where: { name: 'Mugshot' } }, function (err, p) { + p.imageable(function (err, imageable) { + should.not.exist(err); + imageable.should.be.instanceof(Reader); + imageable.name.should.equal('Reader 1'); + done(); + }); + }); + }); + + }); + + describe('polymorphic hasMany', function () { + before(function (done) { + db = getSchema(); + Picture = db.define('Picture', {name: String}); + Author = db.define('Author', {name: String}); + Reader = db.define('Reader', {name: String}); + + db.automigrate(function () { + Picture.destroyAll(function () { + Author.destroyAll(function () { + Reader.destroyAll(done); + }); + }); + }); + }); + + it('can be declared', function (done) { + Author.hasMany(Picture, { polymorphic: 'imageable' }); + Reader.hasMany(Picture, { polymorphic: { // alt syntax + as: 'imageable', foreignKey: 'imageableId', + discriminator: 'imageableType' + } }); + Picture.belongsTo('imageable', { polymorphic: true }); + db.automigrate(done); + }); + + it('should create polymorphic relation - author', function (done) { + Author.create({ name: 'Author 1' }, function (err, author) { + author.pictures.create({ name: 'Author Pic' }, function (err, p) { + should.not.exist(err); + should.exist(p); + p.imageableId.should.eql(author.id); + p.imageableType.should.equal('Author'); + done(); + }); + }); + }); + + it('should create polymorphic relation - reader', function (done) { + Reader.create({ name: 'Reader 1' }, function (err, reader) { + reader.pictures.create({ name: 'Reader Pic' }, function (err, p) { + should.not.exist(err); + should.exist(p); + p.imageableId.should.eql(reader.id); + p.imageableType.should.equal('Reader'); + done(); + }); + }); + }); + + it('should find polymorphic items - author', function (done) { + Author.findOne(function (err, author) { + author.pictures(function (err, pics) { + should.not.exist(err); + pics.should.have.length(1); + pics[0].name.should.equal('Author Pic'); + done(); + }); + }); + }); + + it('should find polymorphic items - reader', function (done) { + Reader.findOne(function (err, reader) { + reader.pictures(function (err, pics) { + should.not.exist(err); + pics.should.have.length(1); + pics[0].name.should.equal('Reader Pic'); + done(); + }); + }); + }); + + it('should find the inverse of polymorphic relation - author', function (done) { + Picture.findOne({ where: { name: 'Author Pic' } }, function (err, p) { + should.not.exist(err); + p.imageableType.should.equal('Author'); + p.imageable(function(err, imageable) { + should.not.exist(err); + imageable.should.be.instanceof(Author); + imageable.name.should.equal('Author 1'); + done(); + }); + }); + }); + + it('should find the inverse of polymorphic relation - reader', function (done) { + Picture.findOne({ where: { name: 'Reader Pic' } }, function (err, p) { + should.not.exist(err); + p.imageableType.should.equal('Reader'); + p.imageable(function(err, imageable) { + should.not.exist(err); + imageable.should.be.instanceof(Reader); + imageable.name.should.equal('Reader 1'); + done(); + }); + }); + }); + + it('should include the inverse of polymorphic relation', function (done) { + Picture.find({ include: 'imageable' }, function (err, pics) { + should.not.exist(err); + pics.should.have.length(2); + pics[0].name.should.equal('Author Pic'); + pics[0].imageable().name.should.equal('Author 1'); + pics[1].name.should.equal('Reader Pic'); + pics[1].imageable().name.should.equal('Reader 1'); + done(); + }); + }); + + it('should assign a polymorphic relation', function(done) { + Author.create({ name: 'Author 2' }, function(err, author) { + var p = new Picture({ name: 'Sample' }); + p.imageable(author); // assign + p.imageableId.should.eql(author.id); + p.imageableType.should.equal('Author'); + p.save(done); + }); + }); + + it('should find polymorphic items - author', function (done) { + Author.findOne({ where: { name: 'Author 2' } }, function (err, author) { + author.pictures(function (err, pics) { + should.not.exist(err); + pics.should.have.length(1); + pics[0].name.should.equal('Sample'); + done(); + }); + }); + }); + + it('should find the inverse of polymorphic relation - author', function (done) { + Picture.findOne({ where: { name: 'Sample' } }, function (err, p) { + should.not.exist(err); + p.imageableType.should.equal('Author'); + p.imageable(function(err, imageable) { + should.not.exist(err); + imageable.should.be.instanceof(Author); + imageable.name.should.equal('Author 2'); + done(); + }); + }); + }); + + }); + + describe('polymorphic hasAndBelongsToMany through', function () { + before(function (done) { + db = getSchema(); + Picture = db.define('Picture', {name: String}); + Author = db.define('Author', {name: String}); + Reader = db.define('Reader', {name: String}); + PictureLink = db.define('PictureLink', {}); + + db.automigrate(function () { + Picture.destroyAll(function () { + PictureLink.destroyAll(function () { + Author.destroyAll(function () { + Reader.destroyAll(done); + }); + }); + }); + }); + }); + + it('can be declared', function (done) { + Author.hasAndBelongsToMany(Picture, { through: PictureLink, polymorphic: 'imageable' }); + Reader.hasAndBelongsToMany(Picture, { through: PictureLink, polymorphic: 'imageable' }); + // Optionally, define inverse relations: + Picture.hasMany(Author, { through: PictureLink, polymorphic: 'imageable', invert: true }); + Picture.hasMany(Reader, { through: PictureLink, polymorphic: 'imageable', invert: true }); + db.automigrate(done); + }); + + var author, reader, pictures = []; + it('should create polymorphic relation - author', function (done) { + Author.create({ name: 'Author 1' }, function (err, a) { + should.not.exist(err); + author = a; + author.pictures.create({ name: 'Author Pic 1' }, function (err, p) { + should.not.exist(err); + pictures.push(p); + author.pictures.create({ name: 'Author Pic 2' }, function (err, p) { + should.not.exist(err); + pictures.push(p); + done(); + }); + }); + }); + }); + + it('should create polymorphic relation - reader', function (done) { + Reader.create({ name: 'Reader 1' }, function (err, r) { + should.not.exist(err); + reader = r; + reader.pictures.create({ name: 'Reader Pic 1' }, function (err, p) { + should.not.exist(err); + pictures.push(p); + done(); + }); + }); + }); + + it('should create polymorphic through model', function (done) { + PictureLink.findOne(function(err, link) { + should.not.exist(err); + link.pictureId.should.eql(pictures[0].id); // eql for mongo ObjectId + link.imageableId.should.eql(author.id); + link.imageableType.should.equal('Author'); + link.imageable(function(err, imageable) { + imageable.should.be.instanceof(Author); + imageable.id.should.eql(author.id); + done(); + }); + }); + }); + + it('should get polymorphic relation through model - author', function (done) { + Author.findById(author.id, function(err, author) { + should.not.exist(err); + author.name.should.equal('Author 1'); + author.pictures(function(err, pics) { + should.not.exist(err); + pics.should.have.length(2); + pics[0].name.should.equal('Author Pic 1'); + pics[1].name.should.equal('Author Pic 2'); + done(); + }); + }); + }); + + it('should get polymorphic relation through model - reader', function (done) { + Reader.findById(reader.id, function(err, reader) { + should.not.exist(err); + reader.name.should.equal('Reader 1'); + reader.pictures(function(err, pics) { + should.not.exist(err); + pics.should.have.length(1); + pics[0].name.should.equal('Reader Pic 1'); + done(); + }); + }); + }); + + it('should include polymorphic items', function (done) { + Author.find({ include: 'pictures' }, function(err, authors) { + authors.should.have.length(1); + authors[0].pictures(function(err, pics) { + pics.should.have.length(2); + pics[0].name.should.equal('Author Pic 1'); + pics[1].name.should.equal('Author Pic 2'); + done(); + }); + }); + }); + + var anotherPicture; + it('should add to a polymorphic relation - author', function (done) { + Author.findById(author.id, function(err, author) { + Picture.create({name: 'Example' }, function(err, p) { + should.not.exist(err); + pictures.push(p); + anotherPicture = p; + author.pictures.add(p, function(err, link) { + link.should.be.instanceof(PictureLink); + link.pictureId.should.eql(p.id); + link.imageableId.should.eql(author.id); + link.imageableType.should.equal('Author'); + done(); + }); + }); + }); + }); + + it('should create polymorphic through model', function (done) { + PictureLink.findOne({ where: { pictureId: anotherPicture.id, imageableType: 'Author' } }, function(err, link) { + should.not.exist(err); + link.pictureId.should.eql(anotherPicture.id); + link.imageableId.should.eql(author.id); + link.imageableType.should.equal('Author'); + done(); + }); + }); + + var anotherAuthor, anotherReader; + it('should add to a polymorphic relation - author', function (done) { + Author.create({ name: 'Author 2' }, function (err, author) { + should.not.exist(err); + anotherAuthor = author; + author.pictures.add(anotherPicture.id, function (err, p) { + should.not.exist(err); + done(); + }); + }); + }); + + it('should add to a polymorphic relation - author', function (done) { + Reader.create({name: 'Reader 2' }, function (err, reader) { + should.not.exist(err); + anotherReader = reader; + reader.pictures.add(anotherPicture.id, function (err, p) { + should.not.exist(err); + done(); + }); + }); + }); + + it('should get the inverse polymorphic relation - author', function (done) { + Picture.findById(anotherPicture.id, function(err, p) { + p.authors(function(err, authors) { + authors.should.have.length(2); + authors[0].name.should.equal('Author 1'); + authors[1].name.should.equal('Author 2'); + done(); + }); + }); + }); + + it('should get the inverse polymorphic relation - reader', function (done) { + Picture.findById(anotherPicture.id, function(err, p) { + p.readers(function(err, readers) { + readers.should.have.length(1); + readers[0].name.should.equal('Reader 2'); + done(); + }); + }); + }); + + it('should find polymorphic items - author', function (done) { + Author.findById(author.id, function(err, author) { + author.pictures(function(err, pics) { + pics.should.have.length(3); + pics[0].name.should.equal('Author Pic 1'); + pics[1].name.should.equal('Author Pic 2'); + pics[2].name.should.equal('Example'); + done(); + }); + }); + }); + + it('should check if polymorphic relation exists - author', function (done) { + Author.findById(author.id, function(err, author) { + author.pictures.exists(anotherPicture.id, function(err, exists) { + exists.should.be.true; + done(); + }); + }); + }); + + it('should remove from a polymorphic relation - author', function (done) { + Author.findById(author.id, function(err, author) { + author.pictures.remove(anotherPicture.id, function(err) { + should.not.exist(err); + done(); + }); + }); + }); + + it('should find polymorphic items - author', function (done) { + Author.findById(author.id, function(err, author) { + author.pictures(function(err, pics) { + pics.should.have.length(2); + pics[0].name.should.equal('Author Pic 1'); + pics[1].name.should.equal('Author Pic 2'); + done(); + }); + }); + }); + + it('should check if polymorphic relation exists - author', function (done) { + Author.findById(author.id, function(err, author) { + author.pictures.exists(7, function(err, exists) { + exists.should.be.false; + done(); + }); + }); + }); + + }); describe('belongsTo', function () { var List, Item, Fear, Mind; @@ -596,10 +1074,12 @@ describe('relations', function () { }); db.automigrate(done); }); - + + var personCreated; it('should create record on scope', function (done) { var p = new Passport({ name: 'Passport', notes: 'Some notes...' }); - p.person.create({ id: 3, name: 'Fred', age: 36 }, function(err, person) { + p.person.create({name: 'Fred', age: 36 }, function(err, person) { + personCreated = person; p.personId.should.equal(person.id); p.save(function (err, p) { person.name.should.equal('Fred'); @@ -611,7 +1091,7 @@ describe('relations', function () { it('should find record on scope', function (done) { Passport.findOne(function (err, p) { - p.personId.should.equal(3); + p.personId.should.eql(personCreated.id); p.person(function(err, person) { person.name.should.equal('Fred'); person.should.not.have.property('age'); @@ -739,5 +1219,887 @@ describe('relations', function () { should.equal(Article.prototype.tags._targetClass, 'Tag'); }); }); + + describe('embedsMany', function () { + + var address1, address2; + + before(function (done) { + db = getSchema(); + Person = db.define('Person', {name: String}); + Address = db.define('Address', {street: String}); + Address.validatesPresenceOf('street'); + + db.automigrate(function () { + Person.destroyAll(done); + }); + }); + + it('can be declared', function (done) { + Person.embedsMany(Address); + db.automigrate(done); + }); + + it('should have setup embedded accessor/scope', function() { + var p = new Person({ name: 'Fred' }); + p.addresses.should.be.an.array; + p.addresses.should.have.length(0); + p.addressList.should.be.a.function; + p.addressList.findById.should.be.a.function; + p.addressList.updateById.should.be.a.function; + p.addressList.destroy.should.be.a.function; + p.addressList.exists.should.be.a.function; + p.addressList.create.should.be.a.function; + p.addressList.build.should.be.a.function; + }); + + it('should create embedded items on scope', function(done) { + Person.create({ name: 'Fred' }, function(err, p) { + p.addressList.create({ street: 'Street 1' }, function(err, address) { + should.not.exist(err); + address1 = address; + should.exist(address1.id); + address1.street.should.equal('Street 1'); + done(); + }); + }); + }); + + it('should create embedded items on scope', function(done) { + Person.findOne(function(err, p) { + p.addressList.create({ street: 'Street 2' }, function(err, address) { + should.not.exist(err); + address2 = address; + should.exist(address2.id); + address2.street.should.equal('Street 2'); + done(); + }); + }); + }); + + it('should return embedded items from scope', function(done) { + Person.findOne(function(err, p) { + p.addressList(function(err, addresses) { + should.not.exist(err); + addresses.should.have.length(2); + addresses[0].id.should.eql(address1.id); + addresses[0].street.should.equal('Street 1'); + addresses[1].id.should.eql(address2.id); + addresses[1].street.should.equal('Street 2'); + done(); + }); + }); + }); + + it('should filter embedded items on scope', function(done) { + Person.findOne(function(err, p) { + p.addressList({ where: { street: 'Street 2' } }, function(err, addresses) { + should.not.exist(err); + addresses.should.have.length(1); + addresses[0].id.should.eql(address2.id); + addresses[0].street.should.equal('Street 2'); + done(); + }); + }); + }); + + it('should validate embedded items', function(done) { + Person.findOne(function(err, p) { + p.addressList.create({}, function(err, address) { + should.exist(err); + should.not.exist(address); + err.name.should.equal('ValidationError'); + err.details.codes.street.should.eql(['presence']); + done(); + }); + }); + }); + + it('should find embedded items by id', function(done) { + Person.findOne(function(err, p) { + p.addressList.findById(address2.id, function(err, address) { + address.should.be.instanceof(Address); + address.id.should.eql(address2.id); + address.street.should.equal('Street 2'); + done(); + }); + }); + }); + + it('should check if item exists', function(done) { + Person.findOne(function(err, p) { + p.addressList.exists(address2.id, function(err, exists) { + should.not.exist(err); + exists.should.be.true; + done(); + }); + }); + }); + + it('should update embedded items by id', function(done) { + Person.findOne(function(err, p) { + p.addressList.updateById(address2.id, { street: 'New Street' }, function(err, address) { + address.should.be.instanceof(Address); + address.id.should.eql(address2.id); + address.street.should.equal('New Street'); + done(); + }); + }); + }); + + it('should validate the update of embedded items', function(done) { + Person.findOne(function(err, p) { + p.addressList.updateById(address2.id, { street: null }, function(err, address) { + err.name.should.equal('ValidationError'); + err.details.codes.street.should.eql(['presence']); + done(); + }); + }); + }); + + it('should find embedded items by id - verify', function(done) { + Person.findOne(function(err, p) { + p.addressList.findById(address2.id, function(err, address) { + address.should.be.instanceof(Address); + address.id.should.eql(address2.id); + address.street.should.equal('New Street'); + done(); + }); + }); + }); + + it('should have accessors: at, get, set', function(done) { + Person.findOne(function(err, p) { + p.addressList.at(0).id.should.equal(address1.id); + p.addressList.get(address1.id).id.should.equal(address1.id); + p.addressList.set(address1.id, { street: 'Changed 1' }); + p.addresses[0].street.should.equal('Changed 1'); + p.addressList.at(1).id.should.equal(address2.id); + p.addressList.get(address2.id).id.should.equal(address2.id); + p.addressList.set(address2.id, { street: 'Changed 2' }); + p.addresses[1].street.should.equal('Changed 2'); + done(); + }); + }); + + it('should remove embedded items by id', function(done) { + Person.findOne(function(err, p) { + p.addresses.should.have.length(2); + p.addressList.destroy(address1.id, function(err) { + should.not.exist(err); + p.addresses.should.have.length(1); + done(); + }); + }); + }); + + it('should have embedded items - verify', function(done) { + Person.findOne(function(err, p) { + p.addresses.should.have.length(1); + done(); + }); + }); + + }); + + describe('embedsMany - explicit ids', function () { + before(function (done) { + db = getSchema(); + Person = db.define('Person', {name: String}); + Address = db.define('Address', {id: { type: String, id: true }, street: String}); + Address.validatesPresenceOf('street'); + + db.automigrate(function () { + Person.destroyAll(done); + }); + }); + + it('can be declared', function (done) { + Person.embedsMany(Address, { options: { autoId: false, validate: true } }); + db.automigrate(done); + }); + + it('should create embedded items on scope', function(done) { + Person.create({ name: 'Fred' }, function(err, p) { + p.addressList.create({ id: 'home', street: 'Street 1' }, function(err, addresses) { + should.not.exist(err); + p.addressList.create({ id: 'work', street: 'Work Street 2' }, function(err, address) { + should.not.exist(err); + address.id.should.equal('work'); + address.street.should.equal('Work Street 2'); + done(); + }); + }); + }); + }); + + it('should find embedded items by id', function(done) { + Person.findOne(function(err, p) { + p.addressList.findById('work', function(err, address) { + address.should.be.instanceof(Address); + address.id.should.equal('work'); + address.street.should.equal('Work Street 2'); + done(); + }); + }); + }); + + it('should check for duplicate ids', function(done) { + Person.findOne(function(err, p) { + p.addressList.create({ id: 'home', street: 'Invalid' }, function(err, addresses) { + should.exist(err); + err.name.should.equal('ValidationError'); + err.details.codes.addresses.should.eql(['uniqueness']); + done(); + }); + }); + }); + + it('should update embedded items by id', function(done) { + Person.findOne(function(err, p) { + p.addressList.updateById('home', { street: 'New Street' }, function(err, address) { + address.should.be.instanceof(Address); + address.id.should.equal('home'); + address.street.should.equal('New Street'); + done(); + }); + }); + }); + + it('should remove embedded items by id', function(done) { + Person.findOne(function(err, p) { + p.addresses.should.have.length(2); + p.addressList.destroy('home', function(err) { + should.not.exist(err); + p.addresses.should.have.length(1); + done(); + }); + }); + }); + + it('should have embedded items - verify', function(done) { + Person.findOne(function(err, p) { + p.addresses.should.have.length(1); + done(); + }); + }); + + it('should validate all embedded items', function(done) { + var addresses = []; + addresses.push({ id: 'home', street: 'Home Street' }); + addresses.push({ id: 'work', street: '' }); + Person.create({ name: 'Wilma', addresses: addresses }, function(err, p) { + err.name.should.equal('ValidationError'); + var expected = 'The `Person` instance is not valid. '; + expected += 'Details: `addresses` contains invalid item: `work` (street can\'t be blank).'; + err.message.should.equal(expected); + done(); + }); + }); + + it('should build embedded items', function(done) { + Person.create({ name: 'Wilma' }, function(err, p) { + p.addressList.build({ id: 'home', street: 'Home' }); + p.addressList.build({ id: 'work', street: 'Work' }); + p.addresses.should.have.length(2); + p.save(function(err, p) { + done(); + }); + }); + }); + + it('should have embedded items - verify', function(done) { + Person.findOne({ where: { name: 'Wilma' } }, function(err, p) { + p.name.should.equal('Wilma'); + p.addresses.should.have.length(2); + p.addresses[0].id.should.equal('home'); + p.addresses[0].street.should.equal('Home'); + p.addresses[1].id.should.equal('work'); + p.addresses[1].street.should.equal('Work'); + done(); + }); + }); + + it('should have accessors: at, get, set', function(done) { + Person.findOne({ where: { name: 'Wilma' } }, function(err, p) { + p.name.should.equal('Wilma'); + p.addresses.should.have.length(2); + p.addressList.at(0).id.should.equal('home'); + p.addressList.get('home').id.should.equal('home'); + p.addressList.set('home', { id: 'den' }).id.should.equal('den'); + p.addressList.at(1).id.should.equal('work'); + p.addressList.get('work').id.should.equal('work'); + p.addressList.set('work', { id: 'factory' }).id.should.equal('factory'); + done(); + }); + }); + + }); + + describe('embedsMany - relations, scope and properties', function () { + + var product1, product2, product3; + + before(function (done) { + db = getSchema(); + Category = db.define('Category', {name: String}); + Product = db.define('Product', {name: String}); + Link = db.define('Link', {name: String}); + + db.automigrate(function () { + Person.destroyAll(done); + }); + }); + + it('can be declared', function (done) { + Category.embedsMany(Link, { + as: 'items', // rename + scope: { include: 'product' }, // always include + options: { belongsTo: 'product' } // optional, for add()/remove() + }); + Link.belongsTo(Product, { + foreignKey: 'id', // re-use the actual product id + properties: { id: 'id', name: 'name' }, // denormalize, transfer id + }); + db.automigrate(function() { + Product.create({ name: 'Product 0' }, done); // offset ids for tests + }); + }); + + it('should setup related items', function(done) { + Product.create({ name: 'Product 1' }, function(err, p) { + product1 = p; + Product.create({ name: 'Product 2' }, function(err, p) { + product2 = p; + Product.create({ name: 'Product 3' }, function(err, p) { + product3 = p; + done(); + }); + }); + }); + }); + + it('should create items on scope', function(done) { + Category.create({ name: 'Category A' }, function(err, cat) { + var link = cat.items.build(); + link.product(product1); + var link = cat.items.build(); + link.product(product2); + cat.save(function(err, cat) { + var product = cat.items.at(0); + product.should.be.instanceof(Link); + product.should.not.have.property('productId'); + product.id.should.eql(product1.id); + product.name.should.equal(product1.name); + var product = cat.items.at(1); + product.id.should.eql(product2.id); + product.name.should.equal(product2.name); + done(); + }); + }); + }); + + it('should include related items on scope', function(done) { + Category.findOne(function(err, cat) { + cat.links.should.have.length(2); + + // denormalized properties: + cat.items.at(0).should.be.instanceof(Link); + cat.items.at(0).id.should.eql(product1.id); + cat.items.at(0).name.should.equal(product1.name); + cat.items.at(1).id.should.eql(product2.id); + cat.items.at(1).name.should.equal(product2.name); + + // lazy-loaded relations + should.not.exist(cat.items.at(0).product()); + should.not.exist(cat.items.at(1).product()); + + cat.items(function(err, items) { + cat.items.at(0).product().should.be.instanceof(Product); + cat.items.at(1).product().should.be.instanceof(Product); + cat.items.at(1).product().name.should.equal('Product 2'); + done(); + }); + }); + }); + + it('should remove embedded items by id', function(done) { + Category.findOne(function(err, cat) { + cat.links.should.have.length(2); + cat.items.destroy(product1.id, function(err) { + should.not.exist(err); + cat.links.should.have.length(1); + done(); + }); + }); + }); + + it('should find items on scope', function(done) { + Category.findOne(function(err, cat) { + cat.links.should.have.length(1); + cat.items.at(0).id.should.eql(product2.id); + cat.items.at(0).name.should.equal(product2.name); + + // lazy-loaded relations + should.not.exist(cat.items.at(0).product()); + + cat.items(function(err, items) { + cat.items.at(0).product().should.be.instanceof(Product); + cat.items.at(0).product().name.should.equal('Product 2'); + done(); + }); + }); + }); + + it('should add related items to scope', function(done) { + Category.findOne(function(err, cat) { + cat.links.should.have.length(1); + cat.items.add(product3, function(err, link) { + link.should.be.instanceof(Link); + link.id.should.eql(product3.id); + link.name.should.equal('Product 3'); + + cat.links.should.have.length(2); + done(); + }); + }); + }); + + it('should find items on scope', function(done) { + Category.findOne(function(err, cat) { + cat.links.should.have.length(2); + + cat.items.at(0).should.be.instanceof(Link); + cat.items.at(0).id.should.eql(product2.id); + cat.items.at(0).name.should.equal(product2.name); + cat.items.at(1).id.should.eql(product3.id); + cat.items.at(1).name.should.equal(product3.name); + + done(); + }); + }); + + it('should remove embedded items by reference id', function(done) { + Category.findOne(function(err, cat) { + cat.links.should.have.length(2); + cat.items.remove(product2.id, function(err) { + should.not.exist(err); + cat.links.should.have.length(1); + done(); + }); + }); + }); + + it('should remove embedded items by reference id', function(done) { + Category.findOne(function(err, cat) { + cat.links.should.have.length(1); + done(); + }); + }); + + }); + + describe('embedsMany - polymorphic relations', function () { + + var person1, person2; + + before(function (done) { + db = getSchema(); + Book = db.define('Book', {name: String}); + Author = db.define('Author', {name: String}); + Reader = db.define('Reader', {name: String}); + + Link = db.define('Link'); // generic model + Link.validatesPresenceOf('linkedId'); + Link.validatesPresenceOf('linkedType'); + + db.automigrate(function () { + Book.destroyAll(function() { + Author.destroyAll(function() { + Reader.destroyAll(done); + }); + }); + }); + }); + + it('can be declared', function (done) { + Book.embedsMany(Link, { as: 'people', + polymorphic: 'linked', + scope: { include: 'linked' } + }); + Link.belongsTo('linked', { + polymorphic: true, // needs unique auto-id + properties: { name: 'name' } // denormalized + }); + db.automigrate(done); + }); + + it('should setup related items', function(done) { + Author.create({ name: 'Author 1' }, function(err, p) { + person1 = p; + Reader.create({ name: 'Reader 1' }, function(err, p) { + person2 = p; + done(); + }); + }); + }); + + it('should create items on scope', function(done) { + Book.create({ name: 'Book' }, function(err, book) { + var link = book.people.build({ notes: 'Something ...' }); + link.linked(person1); + var link = book.people.build(); + link.linked(person2); + book.save(function(err, book) { + should.not.exist(err); + + var link = book.people.at(0); + link.should.be.instanceof(Link); + link.id.should.equal(1); + link.linkedId.should.eql(person1.id); + link.linkedType.should.equal('Author'); + link.name.should.equal('Author 1'); + + var link = book.people.at(1); + link.should.be.instanceof(Link); + link.id.should.equal(2); + link.linkedId.should.eql(person2.id); + link.linkedType.should.equal('Reader'); + link.name.should.equal('Reader 1'); + + done(); + }); + }); + }); + + it('should include related items on scope', function(done) { + Book.findOne(function(err, book) { + book.links.should.have.length(2); + + var link = book.people.at(0); + link.should.be.instanceof(Link); + link.id.should.equal(1); + link.linkedId.should.eql(person1.id); + link.linkedType.should.equal('Author'); + link.notes.should.equal('Something ...'); + + var link = book.people.at(1); + link.should.be.instanceof(Link); + link.id.should.equal(2); + link.linkedId.should.eql(person2.id); + link.linkedType.should.equal('Reader'); + + // lazy-loaded relations + should.not.exist(book.people.at(0).linked()); + should.not.exist(book.people.at(1).linked()); + + book.people(function(err, people) { + people[0].linked().should.be.instanceof(Author); + people[0].linked().name.should.equal('Author 1'); + people[1].linked().should.be.instanceof(Reader); + people[1].linked().name.should.equal('Reader 1'); + done(); + }); + }); + }); + + it('should include nested related items on scope', function(done) { + + // There's some date duplication going on, so it might + // make sense to override toObject on a case-by-case basis + // to sort this out (delete links, keep people). + // In loopback, an afterRemote filter could do this as well. + + Book.find({ include: 'people' }, function(err, books) { + var obj = books[0].toObject(); + + obj.should.have.property('links'); + obj.should.have.property('people'); + + obj.links.should.have.length(2); + obj.links[0].name.should.equal('Author 1'); + obj.links[1].name.should.equal('Reader 1'); + + obj.people.should.have.length(2); + + obj.people[0].name.should.equal('Author 1'); + obj.people[0].notes.should.equal('Something ...'); + + obj.people[0].linked.name.should.equal('Author 1'); + obj.people[1].linked.name.should.equal('Reader 1'); + + done(); + }); + }); + + }); + + describe('referencesMany', function () { + + var product1, product2, product3; + + before(function (done) { + db = getSchema(); + Category = db.define('Category', {name: String}); + Product = db.define('Product', {name: String}); + + db.automigrate(function () { + Category.destroyAll(function() { + Product.destroyAll(done); + }); + }); + }); + + it('can be declared', function (done) { + var reverse = function(cb) { + var modelInstance = this.modelInstance; + var fk = this.definition.keyFrom; + var ids = modelInstance[fk] || []; + modelInstance.updateAttribute(fk, ids.reverse(), function(err, inst) { + cb(err, inst[fk] || []); + }); + }; + + reverse.shared = true; // remoting + reverse.http = { verb: 'put', path: '/products/reverse' }; + + Category.referencesMany(Product, { scopeMethods: { + reverse: reverse + } }); + + Category.prototype['__reverse__products'].should.be.a.function; + should.exist(Category.prototype['__reverse__products'].shared); + Category.prototype['__reverse__products'].http.should.eql(reverse.http); + + db.automigrate(done); + }); + + it('should setup test records', function (done) { + Product.create({ name: 'Product 1' }, function(err, p) { + product1 = p; + Product.create({ name: 'Product 3' }, function(err, p) { + product3 = p; + done(); + }); + }); + }); + + it('should create record on scope', function (done) { + Category.create({ name: 'Category A' }, function(err, cat) { + cat.productIds.should.be.an.array; + cat.productIds.should.have.length(0); + cat.products.create({ name: 'Product 2' }, function(err, p) { + should.not.exist(err); + cat.productIds.should.have.length(1); + cat.productIds.should.eql([p.id]); + p.name.should.equal('Product 2'); + product2 = p; + done(); + }); + }); + }); + + it('should not allow duplicate record on scope', function (done) { + Category.findOne(function(err, cat) { + cat.productIds = [product2.id, product2.id]; + cat.save(function(err, p) { + should.exist(err); + err.name.should.equal('ValidationError'); + err.details.codes.products.should.eql(['uniqueness']); + var expected = 'The `Category` instance is not valid. '; + expected += 'Details: `products` Contains duplicate `Product` instance.'; + err.message.should.equal(expected); + done(); + }); + }); + }); + + it('should find items on scope', function (done) { + Category.findOne(function(err, cat) { + cat.productIds.should.eql([product2.id]); + cat.products(function(err, products) { + should.not.exist(err); + var p = products[0]; + p.id.should.eql(product2.id); + p.name.should.equal('Product 2'); + done(); + }); + }); + }); + + it('should find items on scope - findById', function (done) { + Category.findOne(function(err, cat) { + cat.productIds.should.eql([product2.id]); + cat.products.findById(product2.id, function(err, p) { + should.not.exist(err); + p.should.be.instanceof(Product); + p.id.should.eql(product2.id); + p.name.should.equal('Product 2'); + done(); + }); + }); + }); + + it('should check if a record exists on scope', function (done) { + Category.findOne(function(err, cat) { + cat.products.exists(product2.id, function(err, exists) { + should.not.exist(err); + should.exist(exists); + done(); + }); + }); + }); + + it('should update a record on scope', function (done) { + Category.findOne(function(err, cat) { + var attrs = { name: 'Product 2 - edit' }; + cat.products.updateById(product2.id, attrs, function(err, p) { + should.not.exist(err); + p.name.should.equal(attrs.name); + done(); + }); + }); + }); + + it('should get a record by index - at', function (done) { + Category.findOne(function(err, cat) { + cat.products.at(0, function(err, p) { + should.not.exist(err); + p.should.be.instanceof(Product); + p.id.should.eql(product2.id); + p.name.should.equal('Product 2 - edit'); + done(); + }); + }); + }); + + it('should add a record to scope - object', function (done) { + Category.findOne(function(err, cat) { + cat.products.add(product1, function(err, prod) { + should.not.exist(err); + cat.productIds.should.eql([product2.id, product1.id]); + prod.id.should.eql(product1.id); + prod.should.have.property('name'); + done(); + }); + }); + }); + + it('should add a record to scope - object', function (done) { + Category.findOne(function(err, cat) { + cat.products.add(product3.id, function(err, prod) { + should.not.exist(err); + var expected = [product2.id, product1.id, product3.id]; + cat.productIds.should.eql(expected); + prod.id.should.eql(product3.id); + prod.should.have.property('name'); + done(); + }); + }); + }); + + it('should find items on scope - findById', function (done) { + Category.findOne(function(err, cat) { + cat.products.findById(product3.id, function(err, p) { + should.not.exist(err); + p.id.should.eql(product3.id); + p.name.should.equal('Product 3'); + done(); + }); + }); + }); + + it('should find items on scope - filter', function (done) { + Category.findOne(function(err, cat) { + var filter = { where: { name: 'Product 1' } }; + cat.products(filter, function(err, products) { + should.not.exist(err); + products.should.have.length(1); + var p = products[0]; + p.id.should.eql(product1.id); + p.name.should.equal('Product 1'); + done(); + }); + }); + }); + + it('should remove items from scope', function (done) { + Category.findOne(function(err, cat) { + cat.products.remove(product1.id, function(err, ids) { + should.not.exist(err); + var expected = [product2.id, product3.id]; + cat.productIds.should.eql(expected); + ids.should.eql(cat.productIds); + done(); + }); + }); + }); + + it('should find items on scope - verify', function (done) { + Category.findOne(function(err, cat) { + var expected = [product2.id, product3.id]; + cat.productIds.should.eql(expected); + cat.products(function(err, products) { + should.not.exist(err); + products.should.have.length(2); + products[0].id.should.eql(product2.id); + products[1].id.should.eql(product3.id); + done(); + }); + }); + }); + + it('should allow custom scope methods - reverse', function(done) { + Category.findOne(function(err, cat) { + cat.products.reverse(function(err, ids) { + var expected = [product3.id, product2.id]; + ids.should.eql(expected); + cat.productIds.should.eql(expected); + done(); + }); + }) + }); + + it('should include related items from scope', function(done) { + Category.find({ include: 'products' }, function(err, categories) { + categories.should.have.length(1); + var cat = categories[0].toObject(); + cat.name.should.equal('Category A'); + cat.products.should.have.length(2); + cat.products[0].id.should.eql(product3.id); + cat.products[1].id.should.eql(product2.id); + done(); + }); + }); + + it('should destroy items from scope - destroyById', function (done) { + Category.findOne(function(err, cat) { + cat.products.destroy(product2.id, function(err) { + should.not.exist(err); + var expected = [product3.id]; + cat.productIds.should.eql(expected); + Product.exists(product2.id, function(err, exists) { + should.not.exist(err); + should.exist(exists); + done(); + }); + }); + }); + }); + + it('should find items on scope - verify', function (done) { + Category.findOne(function(err, cat) { + var expected = [product3.id]; + cat.productIds.should.eql(expected); + cat.products(function(err, products) { + should.not.exist(err); + products.should.have.length(1); + products[0].id.should.eql(product3.id); + done(); + }); + }); + }); + + }); });