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/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/relation-definition.js b/lib/relation-definition.js index cae23603..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); @@ -75,6 +112,7 @@ function RelationDefinition(definition) { this.properties = definition.properties || {}; this.options = definition.options || {}; this.scope = definition.scope; + this.embed = definition.embed === true; } RelationDefinition.prototype.toJSON = function () { @@ -100,6 +138,7 @@ 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 @@ -290,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 @@ -455,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 = {}; @@ -505,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; @@ -534,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; @@ -1013,6 +1098,11 @@ BelongsTo.prototype.related = function (refresh, params) { 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 @@ -1375,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/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 a6d50981..bb054a7a 100644 --- a/test/relations.test.js +++ b/test/relations.test.js @@ -4,6 +4,8 @@ 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 () { @@ -580,7 +582,7 @@ describe('relations', function () { author.avatar(function (err, p) { should.not.exist(err); p.name.should.equal('Avatar'); - p.imageableId.should.equal(author.id); + p.imageableId.should.eql(author.id); p.imageableType.should.equal('Author'); done(); }); @@ -592,7 +594,7 @@ describe('relations', function () { reader.mugshot(function (err, p) { should.not.exist(err); p.name.should.equal('Mugshot'); - p.imageableId.should.equal(reader.id); + p.imageableId.should.eql(reader.id); p.imageableType.should.equal('Reader'); done(); }); @@ -654,7 +656,7 @@ describe('relations', function () { author.pictures.create({ name: 'Author Pic' }, function (err, p) { should.not.exist(err); should.exist(p); - p.imageableId.should.equal(author.id); + p.imageableId.should.eql(author.id); p.imageableType.should.equal('Author'); done(); }); @@ -666,7 +668,7 @@ describe('relations', function () { reader.pictures.create({ name: 'Reader Pic' }, function (err, p) { should.not.exist(err); should.exist(p); - p.imageableId.should.equal(reader.id); + p.imageableId.should.eql(reader.id); p.imageableType.should.equal('Reader'); done(); }); @@ -737,7 +739,7 @@ describe('relations', function () { Author.create({ name: 'Author 2' }, function(err, author) { var p = new Picture({ name: 'Sample' }); p.imageable(author); // assign - p.imageableId.should.equal(author.id); + p.imageableId.should.eql(author.id); p.imageableType.should.equal('Author'); p.save(done); }); @@ -1089,7 +1091,7 @@ describe('relations', function () { it('should find record on scope', function (done) { Passport.findOne(function (err, p) { - p.personId.should.equal(personCreated.id); + p.personId.should.eql(personCreated.id); p.person(function(err, person) { person.name.should.equal('Fred'); person.should.not.have.property('age'); @@ -1217,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(); + }); + }); + }); + + }); });