'use strict'; var Utils = require('./../utils') , Helpers = require('./helpers') , _ = require('lodash') , Association = require('./base') , CounterCache = require('../plugins/counter-cache') , util = require('util'); var BelongsToMany = function(source, target, options) { Association.call(this); this.associationType = 'BelongsToMany'; this.source = source; this.target = target; this.targetAssociation = null; this.options = options || {}; this.sequelize = source.modelManager.sequelize; this.through = options.through; this.scope = options.scope; this.isMultiAssociation = true; this.isSelfAssociation = this.source === this.target; this.doubleLinked = false; this.as = this.options.as; if (this.as) { this.isAliased = true; if (Utils._.isPlainObject(this.as)) { this.options.name = this.as; this.as = this.as.plural; } else { this.options.name = { plural: this.as, singular: Utils.singularize(this.as) }; } } else { this.as = this.target.options.name.plural; this.options.name = this.target.options.name; } this.combinedTableName = Utils.combineTableNames( this.source.tableName, this.isSelfAssociation ? (this.as || this.target.tableName) : this.target.tableName ); if (this.through === undefined || this.through === true || this.through === null) { throw new Error('belongsToMany must be given a through option, either a string or a model'); } if (!this.through.model) { this.through = { model: this.through }; } /* * If self association, this is the target association - Unless we find a pairing association */ if (this.isSelfAssociation) { if (!this.as) { throw new Error('\'as\' must be defined for many-to-many self-associations'); } this.targetAssociation = this; } /* * Default/generated foreign/other keys */ if (_.isObject(this.options.foreignKey)) { this.foreignKeyAttribute = this.options.foreignKey; this.foreignKey = this.foreignKeyAttribute.name || this.foreignKeyAttribute.fieldName; } else { if (!this.options.foreignKey) { this.foreignKeyDefault = true; } this.foreignKeyAttribute = {}; this.foreignKey = this.options.foreignKey || _.camelizeIf( [ _.underscoredIf(this.source.options.name.singular, this.source.options.underscored), this.source.primaryKeyAttribute ].join('_'), !this.source.options.underscored ); } if (_.isObject(this.options.otherKey)) { this.otherKeyAttribute = this.options.otherKey; this.otherKey = this.otherKeyAttribute.name || this.otherKeyAttribute.fieldName; } else { if (!this.options.otherKey) { this.otherKeyDefault = true; } this.otherKeyAttribute = {}; this.otherKey = this.options.otherKey || _.camelizeIf( [ _.underscoredIf( this.isSelfAssociation ? Utils.singularize(this.as) : this.target.options.name.singular, this.target.options.underscored ), this.target.primaryKeyAttribute ].join('_'), !this.target.options.underscored ); } /* * Find paired association (if exists) */ _.each(this.target.associations, function(association) { if (association.associationType !== 'BelongsToMany') return; if (association.target !== this.source) return; if (this.options.through.model === association.options.through.model) { this.paired = association; } }, this); if (typeof this.through.model === 'string') { if (!this.sequelize.isDefined(this.through.model)) { this.through.model = this.sequelize.define(this.through.model, {}, _.extend(this.options, { tableName: this.through.model, indexes: {}, //we dont want indexes here (as referenced in #2416) paranoid: false // A paranoid join table does not make sense })); } else { this.through.model = this.sequelize.model(this.through.model); } } if (this.paired) { if (this.otherKeyDefault) { this.otherKey = this.paired.foreignKey; } if (this.paired.otherKeyDefault) { // If paired otherKey was inferred we should make sure to clean it up before adding a new one that matches the foreignKey if (this.paired.otherKey !== this.foreignKey) { delete this.through.model.rawAttributes[this.paired.otherKey]; } this.paired.otherKey = this.foreignKey; this.paired.foreignIdentifier = this.foreignKey; delete this.paired.foreignIdentifierField; } } if (this.through) { this.throughModel = this.through.model; } this.options.tableName = this.combinedName = (this.through.model === Object(this.through.model) ? this.through.model.tableName : this.through.model); this.associationAccessor = this.as; // Get singular and plural names, trying to uppercase the first letter, unless the model forbids it var plural = Utils.uppercaseFirst(this.options.name.plural) , singular = Utils.uppercaseFirst(this.options.name.singular); this.accessors = { get: 'get' + plural, set: 'set' + plural, addMultiple: 'add' + plural, add: 'add' + singular, create: 'create' + singular, remove: 'remove' + singular, removeMultiple: 'remove' + plural, hasSingle: 'has' + singular, hasAll: 'has' + plural }; if (this.options.counterCache) { new CounterCache(this, this.options.counterCache !== true ? this.options.counterCache : {}); } }; util.inherits(BelongsToMany, Association); // the id is in the target table // or in an extra table which connects two tables BelongsToMany.prototype.injectAttributes = function() { var self = this; this.identifier = this.foreignKey; this.foreignIdentifier = this.otherKey; // remove any PKs previously defined by sequelize _.each(this.through.model.rawAttributes, function(attribute, attributeName) { if (attribute.primaryKey === true && attribute._autoGenerated === true) { delete self.through.model.rawAttributes[attributeName]; self.primaryKeyDeleted = true; } }); var sourceKey = this.source.rawAttributes[this.source.primaryKeyAttribute] , sourceKeyType = sourceKey.type , sourceKeyField = sourceKey.field || this.source.primaryKeyAttribute , targetKey = this.target.rawAttributes[this.target.primaryKeyAttribute] , targetKeyType = targetKey.type , targetKeyField = targetKey.field || this.target.primaryKeyAttribute , sourceAttribute = _.defaults(this.foreignKeyAttribute, { type: sourceKeyType }) , targetAttribute = _.defaults(this.otherKeyAttribute, { type: targetKeyType }); if (this.primaryKeyDeleted === true) { targetAttribute.primaryKey = sourceAttribute.primaryKey = true; } else if (this.through.unique !== false) { var uniqueKey = [this.through.model.tableName, this.identifier, this.foreignIdentifier, 'unique'].join('_'); targetAttribute.unique = sourceAttribute.unique = uniqueKey; } if (!this.through.model.rawAttributes[this.identifier]) { this.through.model.rawAttributes[this.identifier] = { _autoGenerated: true }; } if (!this.through.model.rawAttributes[this.foreignIdentifier]) { this.through.model.rawAttributes[this.foreignIdentifier] = { _autoGenerated: true }; } if (this.options.constraints !== false) { sourceAttribute.references = { model: this.source.getTableName(), key: sourceKeyField }; // For the source attribute the passed option is the priority sourceAttribute.onDelete = this.options.onDelete || this.through.model.rawAttributes[this.identifier].onDelete; sourceAttribute.onUpdate = this.options.onUpdate || this.through.model.rawAttributes[this.identifier].onUpdate; if (!sourceAttribute.onDelete) sourceAttribute.onDelete = 'CASCADE'; if (!sourceAttribute.onUpdate) sourceAttribute.onUpdate = 'CASCADE'; targetAttribute.references = { model: this.target.getTableName(), key: targetKeyField }; // But the for target attribute the previously defined option is the priority (since it could've been set by another belongsToMany call) targetAttribute.onDelete = this.through.model.rawAttributes[this.foreignIdentifier].onDelete || this.options.onDelete; targetAttribute.onUpdate = this.through.model.rawAttributes[this.foreignIdentifier].onUpdate || this.options.onUpdate; if (!targetAttribute.onDelete) targetAttribute.onDelete = 'CASCADE'; if (!targetAttribute.onUpdate) targetAttribute.onUpdate = 'CASCADE'; } this.through.model.rawAttributes[this.identifier] = _.extend(this.through.model.rawAttributes[this.identifier], sourceAttribute); this.through.model.rawAttributes[this.foreignIdentifier] = _.extend(this.through.model.rawAttributes[this.foreignIdentifier], targetAttribute); this.identifierField = this.through.model.rawAttributes[this.identifier].field || this.identifier; this.foreignIdentifierField = this.through.model.rawAttributes[this.foreignIdentifier].field || this.foreignIdentifier; if (this.paired && !this.paired.foreignIdentifierField) { this.paired.foreignIdentifierField = this.through.model.rawAttributes[this.paired.foreignIdentifier].field || this.paired.foreignIdentifier; } this.through.model.init(this.through.model.modelManager); Helpers.checkNamingCollision(this); return this; }; BelongsToMany.prototype.injectGetter = function(obj) { var association = this; obj[this.accessors.get] = function(options) { options = association.target.__optClone(options) || {}; var instance = this , through = association.through , scopeWhere , throughWhere; if (association.scope) { scopeWhere = _.clone(association.scope); } options.where = { $and: [ scopeWhere, options.where ] }; if (Object(through.model) === through.model) { throughWhere = {}; throughWhere[association.identifier] = instance.get(association.source.primaryKeyAttribute); if (through && through.scope) { Object.keys(through.scope).forEach(function (attribute) { throughWhere[attribute] = through.scope[attribute]; }.bind(this)); } options.include = options.include || []; options.include.push({ model: through.model, as: through.model.name, attributes: options.joinTableAttributes, association: { isSingleAssociation: true, source: association.target, target: association.source, identifier: association.foreignIdentifier, identifierField: association.foreignIdentifierField }, required: true, where: throughWhere, _pseudo: true }); } var model = association.target; if (options.hasOwnProperty('scope')) { if (!options.scope) { model = model.unscoped(); } else { model = model.scope(options.scope); } } return model.findAll(options); }; obj[this.accessors.hasSingle] = obj[this.accessors.hasAll] = function(instances, options) { var where = {}; if (!Array.isArray(instances)) { instances = [instances]; } options = options || {}; options.scope = false; _.defaults(options, { raw: true }); where.$or = instances.map(function (instance) { if (instance instanceof association.target.Instance) { return instance.where(); } else { var $where = {}; $where[association.target.primaryKeyAttribute] = instance; return $where; } }); options.where = { $and: [ where, options.where ] }; return this[association.accessors.get](options).then(function(associatedObjects) { return associatedObjects.length === instances.length; }); }; return this; }; BelongsToMany.prototype.injectSetter = function(obj) { var association = this; obj[this.accessors.set] = function(newAssociatedObjects, options) { options = options || {}; var instance = this , sourceKey = association.source.primaryKeyAttribute , targetKey = association.target.primaryKeyAttribute , identifier = association.identifier , foreignIdentifier = association.foreignIdentifier , where = {}; if (newAssociatedObjects === null) { newAssociatedObjects = []; } else { newAssociatedObjects = association.toInstanceArray(newAssociatedObjects); } where[identifier] = this.get(sourceKey); return association.through.model.findAll(_.defaults({ where: where, raw: true, }, options)).then(function (currentRows) { var obsoleteAssociations = [] , defaultAttributes = options , promises = [] , unassociatedObjects; // Don't try to insert the transaction as an attribute in the through table defaultAttributes = _.omit(defaultAttributes, ['transaction', 'hooks', 'individualHooks', 'ignoreDuplicates', 'validate', 'fields', 'logging']); unassociatedObjects = newAssociatedObjects.filter(function(obj) { return !_.find(currentRows, function(currentRow) { return currentRow[foreignIdentifier] === obj.get(targetKey); }); }); currentRows.forEach(function(currentRow) { var newObj = _.find(newAssociatedObjects, function(obj) { return currentRow[foreignIdentifier] === obj.get(targetKey); }); if (!newObj) { obsoleteAssociations.push(currentRow); } else { var throughAttributes = newObj[association.through.model.name]; // Quick-fix for subtle bug when using existing objects that might have the through model attached (not as an attribute object) if (throughAttributes instanceof association.through.model.Instance) { throughAttributes = {}; } var where = {} , attributes = _.defaults({}, throughAttributes, defaultAttributes); where[identifier] = instance.get(sourceKey); where[foreignIdentifier] = newObj.get(targetKey); if (Object.keys(attributes).length) { promises.push(association.through.model.update(attributes, _.extend(options, { where: where }))); } } }); if (obsoleteAssociations.length > 0) { var where = {}; where[identifier] = instance.get(sourceKey); where[foreignIdentifier] = obsoleteAssociations.map(function(obsoleteAssociation) { return obsoleteAssociation[foreignIdentifier]; }); promises.push(association.through.model.destroy(_.defaults({ where: where }, options))); } if (unassociatedObjects.length > 0) { var bulk = unassociatedObjects.map(function(unassociatedObject) { var attributes = {}; attributes[identifier] = instance.get(sourceKey); attributes[foreignIdentifier] = unassociatedObject.get(targetKey); attributes = _.defaults(attributes, unassociatedObject[association.through.model.name], defaultAttributes); _.assign(attributes, association.through.scope); return attributes; }.bind(this)); promises.push(association.through.model.bulkCreate(bulk, options)); } return Utils.Promise.all(promises); }); }; obj[this.accessors.addMultiple] = obj[this.accessors.add] = function(newInstances, additionalAttributes) { // If newInstances is null or undefined, no-op if (!newInstances) return Utils.Promise.resolve(); additionalAttributes = additionalAttributes || {}; var instance = this , defaultAttributes = _.omit(additionalAttributes, ['transaction', 'hooks', 'individualHooks', 'ignoreDuplicates', 'validate', 'fields', 'logging']) , sourceKey = association.source.primaryKeyAttribute , targetKey = association.target.primaryKeyAttribute , identifier = association.identifier , foreignIdentifier = association.foreignIdentifier , options = additionalAttributes; newInstances = association.toInstanceArray(newInstances); var where = {}; where[identifier] = instance.get(sourceKey); where[foreignIdentifier] = newInstances.map(function (newInstance) { return newInstance.get(targetKey); }); _.assign(where, association.through.scope); return association.through.model.findAll(_.defaults({ where: where, raw: true, }, options)).then(function (currentRows) { var promises = []; var unassociatedObjects = [], changedAssociations = []; newInstances.forEach(function(obj) { var existingAssociation = _.find(currentRows, function(current) { return current[foreignIdentifier] === obj.get(targetKey); }); if (!existingAssociation) { unassociatedObjects.push(obj); } else { var throughAttributes = obj[association.through.model.name] , attributes = _.defaults({}, throughAttributes, defaultAttributes); if (_.any(Object.keys(attributes), function (attribute) { return attributes[attribute] !== existingAssociation[attribute]; })) { changedAssociations.push(obj); } } }); if (unassociatedObjects.length > 0) { var bulk = unassociatedObjects.map(function(unassociatedObject) { var throughAttributes = unassociatedObject[association.through.model.name] , attributes = _.defaults({}, throughAttributes, defaultAttributes); attributes[identifier] = instance.get(sourceKey); attributes[foreignIdentifier] = unassociatedObject.get(targetKey); _.assign(attributes, association.through.scope); return attributes; }.bind(this)); promises.push(association.through.model.bulkCreate(bulk, options)); } changedAssociations.forEach(function(assoc) { var throughAttributes = assoc[association.through.model.name] , attributes = _.defaults({}, throughAttributes, defaultAttributes) , where = {}; // Quick-fix for subtle bug when using existing objects that might have the through model attached (not as an attribute object) if (throughAttributes instanceof association.through.model.Instance) { throughAttributes = {}; } where[identifier] = instance.get(sourceKey); where[foreignIdentifier] = assoc.get(targetKey); promises.push(association.through.model.update(attributes, _.extend(options, { where: where }))); }); return Utils.Promise.all(promises); }); }; obj[this.accessors.removeMultiple] = obj[this.accessors.remove] = function(oldAssociatedObjects, options) { options = options || {}; oldAssociatedObjects = association.toInstanceArray(oldAssociatedObjects); var where = {}; where[association.identifier] = this.get(association.source.primaryKeyAttribute); where[association.foreignIdentifier] = oldAssociatedObjects.map(function (newInstance) { return newInstance.get(association.target.primaryKeyAttribute); }); return association.through.model.destroy(_.defaults({ where: where }, options)); }; return this; }; BelongsToMany.prototype.injectCreator = function(obj) { var association = this; obj[this.accessors.create] = function(values, options) { var instance = this; options = options || {}; values = values || {}; if (Array.isArray(options)) { options = { fields: options }; } if (association.scope) { _.assign(values, association.scope); if (options.fields) { options.fields = options.fields.concat(Object.keys(association.scope)); } } // Create the related model instance return association.target.create(values, options).then(function(newAssociatedObject) { return instance[association.accessors.add](newAssociatedObject, _.omit(options, ['fields'])).return(newAssociatedObject); }); }; return this; }; module.exports = BelongsToMany;