600 lines
20 KiB
JavaScript
600 lines
20 KiB
JavaScript
'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;
|