不要怂,就是干,撸起袖子干!

Commit db5e7c8f by Mick Hansen

Merge pull request #2687 from sequelize/refactor-belongs-to-many

Refactor 2 x hasMany to belongsToMany
2 parents 5d2663d7 112d5d0b
...@@ -81,16 +81,13 @@ Project.hasMany(User, {as: 'Workers'}) ...@@ -81,16 +81,13 @@ Project.hasMany(User, {as: 'Workers'})
This will add the attribute ProjectId or `project_id` to User. Instances of Project will get the accessors getWorkers and setWorkers. We could just leave it the way it is and let it be a one-way association. This will add the attribute ProjectId or `project_id` to User. Instances of Project will get the accessors getWorkers and setWorkers. We could just leave it the way it is and let it be a one-way association.
But we want more! Let's define it the other way around by creating a many to many assocation in the next section: But we want more! Let's define it the other way around by creating a many to many assocation in the next section:
## Many-To-Many associations ## Belongs-To-Many associations
Many-To-Many associations are used to connect sources with multiple targets. Furthermore the targets can also have connections to multiple sources. Belongs-To-Many associations are used to connect sources with multiple targets. Furthermore the targets can also have connections to multiple sources.
```js ```js
// again the Project association to User Project.belongsToMany(User);
Project.hasMany(User) User.belongsToMany(Project);
 
// now comes the association between User and Project
User.hasMany(Project)
``` ```
This will remove the attribute `ProjectId` (or `project_id`) from User and create a new model called ProjectsUsers with the equivalent foreign keys `ProjectId`(or `project_id`) and `UserId` (or `user_id`). Whether the attributes are camelcase or not depends on the two models joined by the table (in this case User and Project). This will remove the attribute `ProjectId` (or `project_id`) from User and create a new model called ProjectsUsers with the equivalent foreign keys `ProjectId`(or `project_id`) and `UserId` (or `user_id`). Whether the attributes are camelcase or not depends on the two models joined by the table (in this case User and Project).
...@@ -99,8 +96,8 @@ This will add methods `getUsers`, `setUsers`, `addUsers` to `Project`, and `getP ...@@ -99,8 +96,8 @@ This will add methods `getUsers`, `setUsers`, `addUsers` to `Project`, and `getP
Sometimes you may want to rename your models when using them in associations. Let's define users as workers and projects as tasks by using the alias (`as`) option: Sometimes you may want to rename your models when using them in associations. Let's define users as workers and projects as tasks by using the alias (`as`) option:
```js ```js
User.hasMany(Project, { as: 'Tasks', through: 'worker_tasks' }) User.belongsToMany(Project, { as: 'Tasks', through: 'worker_tasks' })
Project.hasMany(User, { as: 'Workers', through: 'worker_tasks' }) Project.belongsToMany(User, { as: 'Workers', through: 'worker_tasks' })
``` ```
Notice how we used the `through` option together with the alias in the code above. This is needed to tell sequelize that the two `hasMany` calls are actually two sides of the same association. If you don't use an alias (as shown in the first example of this section) this matching happens Notice how we used the `through` option together with the alias in the code above. This is needed to tell sequelize that the two `hasMany` calls are actually two sides of the same association. If you don't use an alias (as shown in the first example of this section) this matching happens
...@@ -109,13 +106,9 @@ automagically, but with aliassed assocations `through` is required. ...@@ -109,13 +106,9 @@ automagically, but with aliassed assocations `through` is required.
Of course you can also define self references with hasMany: Of course you can also define self references with hasMany:
```js ```js
Person.hasMany(Person, { as: 'Children' }) Person.belongsToMany(Person, { as: 'Children' })
// This will create the table ChildrenPersons which stores the ids of the objects. // This will create the table ChildrenPersons which stores the ids of the objects.
 
// You can also reference the same Model without creating a junction
// table (but only if each object will have just one 'parent'). If you need that,
// use the option foreignKey and set through to null
Comment.hasMany(Comment, { as: 'Children', foreignKey: 'ParentId', through: null })
``` ```
By default, sequelize will handle everything related to the join table for you. However, sometimes you might want some more control over the table. This is where th e`through` options comes in handy. By default, sequelize will handle everything related to the join table for you. However, sometimes you might want some more control over the table. This is where th e`through` options comes in handy.
...@@ -123,8 +116,8 @@ By default, sequelize will handle everything related to the join table for you. ...@@ -123,8 +116,8 @@ By default, sequelize will handle everything related to the join table for you.
If you just want to control the name of the join table, you can pass a string: If you just want to control the name of the join table, you can pass a string:
```js ```js
Project.hasMany(User, {through: 'project_has_users'}) Project.belongsToMany(User, {through: 'project_has_users'})
User.hasMany(Project, {through: 'project_has_users'}) User.belongsToMany(Project, {through: 'project_has_users'})
``` ```
If you want additional attributes in your join table, you can define a model for the join table in sequelize, before you define the association, and then tell sequelize that it should use that model for joining, instead of creating a new one: If you want additional attributes in your join table, you can define a model for the join table in sequelize, before you define the association, and then tell sequelize that it should use that model for joining, instead of creating a new one:
...@@ -136,8 +129,8 @@ UserProjects = sequelize.define('UserProjects', { ...@@ -136,8 +129,8 @@ UserProjects = sequelize.define('UserProjects', {
status: DataTypes.STRING status: DataTypes.STRING
}) })
   
User.hasMany(Project, { through: UserProjects }) User.belongsToMany(Project, { through: UserProjects })
Project.hasMany(User, { through: UserProjects }) Project.belongsToMany(User, { through: UserProjects })
``` ```
To add a new project to a user and set it's status, you pass an extra object to the setter, which contains the attributes for the join table To add a new project to a user and set it's status, you pass an extra object to the setter, which contains the attributes for the join table
...@@ -166,7 +159,7 @@ By default sequelize will use the model name (the name passed to `sequelize.defi ...@@ -166,7 +159,7 @@ By default sequelize will use the model name (the name passed to `sequelize.defi
As we've already seen, you can alias models in associations using `as`. In single assocations (has one and belongs to), the alias should be singular, while for many associations (has many) it should be plural. Sequelize then uses the [inflection ][0]library to convert the alias to its singular form. However, this might not always work for irregular or non-english words. In this case, you can provide both the plural and the singular form of the alias: As we've already seen, you can alias models in associations using `as`. In single assocations (has one and belongs to), the alias should be singular, while for many associations (has many) it should be plural. Sequelize then uses the [inflection ][0]library to convert the alias to its singular form. However, this might not always work for irregular or non-english words. In this case, you can provide both the plural and the singular form of the alias:
```js ```js
User.hasMany(Project, { as: { singular: 'task', plural: 'tasks' }}) User.belongsToMany(Project, { as: { singular: 'task', plural: 'tasks' }})
// Notice that inflection has no problem singularizing tasks, this is just for illustrative purposes. // Notice that inflection has no problem singularizing tasks, this is just for illustrative purposes.
``` ```
...@@ -180,7 +173,7 @@ var Project = sequelize.define('project', attributes, { ...@@ -180,7 +173,7 @@ var Project = sequelize.define('project', attributes, {
} }
}) })
   
User.hasMany(Project); User.belongsToMany(Project);
``` ```
This will add the functions `add/set/get Tasks` to user instances. This will add the functions `add/set/get Tasks` to user instances.
...@@ -190,8 +183,8 @@ This will add the functions `add/set/get Tasks` to user instances. ...@@ -190,8 +183,8 @@ This will add the functions `add/set/get Tasks` to user instances.
Because Sequelize is doing a lot of magic, you have to call `Sequelize.sync` after setting the associations! Doing so will allow you the following: Because Sequelize is doing a lot of magic, you have to call `Sequelize.sync` after setting the associations! Doing so will allow you the following:
```js ```js
Project.hasMany(Task) Project.belongsToMany(Task)
Task.hasMany(Project) Task.belongsToMany(Project)
   
Project.create()... Project.create()...
Task.create()... Task.create()...
......
'use strict';
var Utils = require('./../utils')
, Helpers = require('./helpers')
, _ = require('lodash')
, Association = require('./base')
, Transaction = require('../transaction')
, Model = require('../model')
, CounterCache = require('../plugins/counter-cache')
, HasManyDoubleLinked = require('./has-many-double-linked');
module.exports = (function() {
var BelongsToMany = function(source, target, options) {
Association.call(this);
var self = this;
this.associationType = 'BelongsToMany';
this.source = source;
this.target = target;
this.targetAssociation = null;
this.options = options || {};
this.sequelize = source.daoFactoryManager.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;
this.combinedTableName = Utils.combineTableNames(
this.source.tableName,
this.isSelfAssociation ? (this.as || this.target.tableName) : this.target.tableName
);
if (this.through === undefined) {
this.through = true;
}
if (Utils._.isObject(this.options.foreignKey)) {
this.foreignKeyAttribute = this.options.foreignKey;
this.foreignKey = this.foreignKeyAttribute.name || this.foreignKeyAttribute.fieldName;
} else {
this.foreignKeyAttribute = {};
this.foreignKey = this.options.foreignKey || Utils._.camelizeIf(
[
Utils._.underscoredIf(this.source.options.name.singular, this.source.options.underscored),
this.source.primaryKeyAttribute
].join('_'),
!this.source.options.underscored
);
}
if (Utils._.isObject(this.options.otherKey)) {
this.otherKeyAttribute = this.options.otherKey;
this.otherKey = this.otherKeyAttribute.name || this.otherKeyAttribute.fieldName;
} else {
this.otherKeyAttribute = {};
this.otherKey = this.options.otherKey || Utils._.camelizeIf(
[
Utils._.underscoredIf(this.target.options.name.singular, this.target.options.underscored),
this.target.primaryKeyAttribute
].join('_'),
!this.target.options.underscored
);
}
if (this.through !== null && !this.through.model) {
this.through = {
model: this.through
};
}
/*
* Determine associationAccessor, especially for include options to identify the correct model
*/
this.associationAccessor = this.as;
if (!this.associationAccessor) {
if (typeof this.through.model === 'string') {
this.associationAccessor = this.through.model;
} else if (Object(this.through.model) === this.through.model) {
this.associationAccessor = this.through.model.tableName;
} else {
this.associationAccessor = this.combinedTableName;
}
}
/*
* If self association, this is the target association - Unless we find a pairing association
*/
if (this.isSelfAssociation) {
// check 'as' is defined for many-to-many self-association
if (this.through && this.through.model !== true && !this.as) {
throw new Error('\'as\' must be defined for many-to-many self-associations');
}
this.targetAssociation = this;
}
/*
* If we are double linked, and through is either default or a string, we create the through model and set it on both associations
*/
if (this.through.model === true) {
this.through.model = this.combinedTableName;
}
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.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);
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;
}
// 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 : {});
}
};
// the id is in the target table
// or in an extra table which connects two tables
BelongsToMany.prototype.injectAttributes = function() {
var doubleLinked = this.doubleLinked
, self = this;
this.identifier = this.foreignKey;
this.foreignIdentifier = this.otherKey;
// remove any PKs previously defined by sequelize
Utils._.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 = Utils._.defaults(this.foreignKeyAttribute, { type: sourceKeyType })
, targetAttribute = Utils._.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 = this.source.getTableName();
sourceAttribute.referencesKey = 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 = this.target.getTableName();
targetAttribute.referencesKey = 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) sourceAttribute.onDelete = 'CASCADE';
if (!targetAttribute.onUpdate) sourceAttribute.onUpdate = 'CASCADE';
}
this.through.model.rawAttributes[this.identifier] = Utils._.extend(this.through.model.rawAttributes[this.identifier], sourceAttribute);
this.through.model.rawAttributes[this.foreignIdentifier] = Utils._.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;
this.through.model.init(this.through.model.daoFactoryManager);
Helpers.checkNamingCollision(this);
return this;
};
BelongsToMany.prototype.injectGetter = function(obj) {
var association = this;
obj[this.accessors.get] = function(options, queryOptions) {
options = association.target.__optClone(options) || {};
queryOptions = queryOptions || {};
var instance = this
, through = association.through
, scopeWhere
, throughWhere;
if (association.scope) {
scopeWhere = {};
Object.keys(association.scope).forEach(function (attribute) {
scopeWhere[attribute] = association.scope[attribute];
}.bind(this));
}
options.where = new Utils.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
});
}
return association.target.findAll(options, queryOptions);
};
obj[this.accessors.hasAll] = function(instances, options) {
var instance = this
, where;
options = options || {};
instances.forEach(function(instance) {
if (instance instanceof association.target.Instance) {
where = new Utils.or([where, instance.primaryKeyValues]);
} else {
var _where = {};
_where[association.target.primaryKeyAttribute] = instance;
where = new Utils.or([where, _where]);
}
});
options.where = new Utils.and([
where,
options.where
]);
return instance[association.accessors.get](
options,
{ raw: true }
).then(function(associatedObjects) {
return associatedObjects.length === instances.length;
});
};
obj[this.accessors.hasSingle] = function(param, options) {
var instance = this
, where;
options = options || {};
if (param instanceof association.target.Instance) {
where = param.primaryKeyValues;
} else {
where = {};
where[association.target.primaryKeyAttribute] = param;
}
options.where = new Utils.and([
where,
options.where
]);
return instance[association.accessors.get](
options,
{ raw: true }
).then(function(associatedObjects) {
return associatedObjects.length !== 0;
});
};
return this;
};
BelongsToMany.prototype.injectSetter = function(obj) {
var association = this
, primaryKeyAttribute = association.target.primaryKeyAttribute;
obj[this.accessors.set] = function(newAssociatedObjects, additionalAttributes) {
additionalAttributes = additionalAttributes || {};
if (newAssociatedObjects === null) {
newAssociatedObjects = [];
} else {
newAssociatedObjects = newAssociatedObjects.map(function(newAssociatedObject) {
if (!(newAssociatedObject instanceof association.target.Instance)) {
var tmpInstance = {};
tmpInstance[primaryKeyAttribute] = newAssociatedObject;
return association.target.build(tmpInstance, {
isNewRecord: false
});
}
return newAssociatedObject;
});
}
var instance = this;
return instance[association.accessors.get]({}, {
transaction: additionalAttributes.transaction
}).then(function(oldAssociatedObjects) {
var foreignIdentifier = association.foreignIdentifier
, sourceKeys = Object.keys(association.source.primaryKeys)
, targetKeys = Object.keys(association.target.primaryKeys)
, obsoleteAssociations = []
, changedAssociations = []
, defaultAttributes = additionalAttributes
, options = additionalAttributes
, promises = []
, unassociatedObjects;
defaultAttributes = Utils._.omit(defaultAttributes, ['transaction', 'hooks', 'individualHooks', 'ignoreDuplicates', 'validate', 'fields']); // Don't try to insert the transaction as an attribute in the through table
unassociatedObjects = newAssociatedObjects.filter(function(obj) {
return !Utils._.find(oldAssociatedObjects, function(old) {
return (!!obj[foreignIdentifier] ? obj[foreignIdentifier] === old[foreignIdentifier] : (!!obj[targetKeys[0]] ? obj[targetKeys[0]] === old[targetKeys[0]] : obj.id === old.id));
});
});
oldAssociatedObjects.forEach(function(old) {
var newObj = Utils._.find(newAssociatedObjects, function(obj) {
return (!!obj[foreignIdentifier] ? obj[foreignIdentifier] === old[foreignIdentifier] : (!!obj[targetKeys[0]] ? obj[targetKeys[0]] === old[targetKeys[0]] : obj.id === old.id));
});
if (!newObj) {
obsoleteAssociations.push(old);
} 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 changedAssociation = {
where: {},
attributes: Utils._.defaults({}, throughAttributes, defaultAttributes)
};
changedAssociation.where[association.identifier] = instance[sourceKeys[0]] || instance.id;
changedAssociation.where[foreignIdentifier] = newObj[targetKeys[0]] || newObj.id;
if (Object.keys(changedAssociation.attributes).length) {
changedAssociations.push(changedAssociation);
}
}
});
if (obsoleteAssociations.length > 0) {
var foreignIds = obsoleteAssociations.map(function(associatedObject) {
return ((targetKeys.length === 1) ? associatedObject[targetKeys[0]] : associatedObject.id);
});
var where = {};
where[association.identifier] = ((sourceKeys.length === 1) ? instance[sourceKeys[0]] : instance.id);
where[foreignIdentifier] = foreignIds;
promises.push(association.through.model.destroy(Utils._.extend(options, {
where: where
})));
}
if (unassociatedObjects.length > 0) {
var bulk = unassociatedObjects.map(function(unassociatedObject) {
var attributes = {};
attributes[association.identifier] = ((sourceKeys.length === 1) ? instance[sourceKeys[0]] : instance.id);
attributes[foreignIdentifier] = ((targetKeys.length === 1) ? unassociatedObject[targetKeys[0]] : unassociatedObject.id);
attributes = Utils._.defaults(attributes, unassociatedObject[association.through.model.name], defaultAttributes);
if (association.through.scope) {
Object.keys(association.through.scope).forEach(function (attribute) {
attributes[attribute] = association.through.scope[attribute];
});
}
return attributes;
}.bind(this));
promises.push(association.through.model.bulkCreate(bulk, options));
}
if (changedAssociations.length > 0) {
changedAssociations.forEach(function(assoc) {
promises.push(association.through.model.update(assoc.attributes, Utils._.extend(options, {
where: assoc.where
})));
});
}
return Utils.Promise.all(promises);
});
};
obj[this.accessors.addMultiple] = obj[this.accessors.add] = function(newInstance, additionalAttributes) {
// If newInstance is null or undefined, no-op
if (!newInstance) return Utils.Promise.resolve();
var instance = this
, primaryKeyAttribute = association.target.primaryKeyAttribute;
additionalAttributes = additionalAttributes || {};
if (association.through && association.through.scope) {
Object.keys(association.through.scope).forEach(function (attribute) {
additionalAttributes[attribute] = association.through.scope[attribute];
});
}
if (Array.isArray(newInstance)) {
var newInstances = newInstance.map(function(newInstance) {
if (!(newInstance instanceof association.target.Instance)) {
var tmpInstance = {};
tmpInstance[primaryKeyAttribute] = newInstance;
return association.target.build(tmpInstance, {
isNewRecord: false
});
}
return newInstance;
});
var self = this
, foreignIdentifier = association.foreignIdentifier
, sourceKeys = Object.keys(association.source.primaryKeys)
, targetKeys = Object.keys(association.target.primaryKeys)
, obsoleteAssociations = []
, changedAssociations = []
, defaultAttributes = additionalAttributes || {}
, options = defaultAttributes
, promises = []
, oldAssociations = []
, unassociatedObjects;
defaultAttributes = Utils._.omit(defaultAttributes, ['transaction', 'hooks', 'individualHooks', 'ignoreDuplicates', 'validate', 'fields']); // Don't try to insert the transaction as an attribute in the through table
unassociatedObjects = newInstances.filter(function(obj) {
return !Utils._.find(oldAssociations, function(old) {
return (!!obj[foreignIdentifier] ? obj[foreignIdentifier] === old[foreignIdentifier] : (!!obj[targetKeys[0]] ? obj[targetKeys[0]] === old[targetKeys[0]] : obj.id === old.id));
});
});
oldAssociations.forEach(function(old) {
var newObj = Utils._.find(newInstances, function(obj) {
return (!!obj[foreignIdentifier] ? obj[foreignIdentifier] === old[foreignIdentifier] : (!!obj[targetKeys[0]] ? obj[targetKeys[0]] === old[targetKeys[0]] : obj.id === old.id));
});
if (!newObj) {
obsoleteAssociations.push(old);
} else if (Object(association.through.model) === association.through.model) {
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 changedAssociation = {
where: {},
attributes: Utils._.defaults({}, throughAttributes, defaultAttributes)
};
changedAssociation.where[association.identifier] = instance[sourceKeys[0]] || instance.id;
changedAssociation.where[foreignIdentifier] = newObj[targetKeys[0]] || newObj.id;
if (Object.keys(changedAssociation.attributes).length) {
changedAssociations.push(changedAssociation);
}
}
});
if (obsoleteAssociations.length > 0) {
var foreignIds = obsoleteAssociations.map(function(associatedObject) {
return ((targetKeys.length === 1) ? associatedObject[targetKeys[0]] : associatedObject.id);
});
var where = {};
where[association.identifier] = ((sourceKeys.length === 1) ? instance[sourceKeys[0]] : instance.id);
where[association.foreignIdentifier] = foreignIds;
promises.push(association.through.model.destroy(Utils._.extend(options, {
where: where
})));
}
if (unassociatedObjects.length > 0) {
var bulk = unassociatedObjects.map(function(unassociatedObject) {
var attributes = {};
attributes[association.identifier] = ((sourceKeys.length === 1) ? instance[sourceKeys[0]] : instance.id);
attributes[association.foreignIdentifier] = ((targetKeys.length === 1) ? unassociatedObject[targetKeys[0]] : unassociatedObject.id);
if (Object(association.through.model) === association.through.model) {
attributes = Utils._.defaults(attributes, unassociatedObject[association.through.model.name], defaultAttributes);
}
if (association.through.scope) {
Object.keys(association.through.scope).forEach(function (attribute) {
attributes[attribute] = association.through.scope[attribute];
});
}
return attributes;
}.bind(this));
promises.push(association.through.model.bulkCreate(bulk, options));
}
if (changedAssociations.length > 0) {
changedAssociations.forEach(function(assoc) {
promises.push(association.through.model.update(assoc.attributes, Utils._.extend(options, {
where: assoc.where
})));
});
}
return Utils.Promise.all(promises);
} else {
if (!(newInstance instanceof association.target.Instance)) {
var tmpInstance = {};
tmpInstance[primaryKeyAttribute] = newInstance;
newInstance = association.target.build(tmpInstance, {
isNewRecord: false
});
}
return instance[association.accessors.get]({
where: newInstance.primaryKeyValues
}, {
transaction: (additionalAttributes || {}).transaction
}).then(function(currentAssociatedObjects) {
additionalAttributes = additionalAttributes || {};
var attributes = {}
, foreignIdentifier = association.foreignIdentifier
, options = additionalAttributes;
var sourceKeys = Object.keys(association.source.primaryKeys);
var targetKeys = Object.keys(association.target.primaryKeys);
additionalAttributes = Utils._.omit(additionalAttributes, ['transaction', 'hooks', 'individualHooks', 'ignoreDuplicates', 'validate', 'fields']); // Don't try to insert the transaction as an attribute in the through table
attributes[association.identifier] = ((sourceKeys.length === 1) ? instance[sourceKeys[0]] : instance.id);
attributes[foreignIdentifier] = ((targetKeys.length === 1) ? newInstance[targetKeys[0]] : newInstance.id);
if (!!currentAssociatedObjects.length) {
var where = attributes;
attributes = Utils._.defaults({}, newInstance[association.through.model.name], additionalAttributes);
if (Object.keys(attributes).length) {
return association.through.model.update(attributes, Utils._.extend(options, {
where: where
}));
} else {
return Utils.Promise.resolve();
}
} else {
attributes = Utils._.defaults(attributes, newInstance[association.through.model.name], additionalAttributes);
if (association.through.scope) {
Object.keys(association.through.scope).forEach(function (attribute) {
attributes[attribute] = association.through.scope[attribute];
});
}
return association.through.model.create(attributes, options);
}
});
}
};
obj[this.accessors.remove] = function(oldAssociatedObject, options) {
var instance = this;
return instance[association.accessors.get]({}, options).then(function(currentAssociatedObjects) {
var newAssociations = [];
if (!(oldAssociatedObject instanceof association.target.Instance)) {
var tmpInstance = {};
tmpInstance[primaryKeyAttribute] = oldAssociatedObject;
oldAssociatedObject = association.target.build(tmpInstance, {
isNewRecord: false
});
}
currentAssociatedObjects.forEach(function(association) {
if (!Utils._.isEqual(oldAssociatedObject.identifiers, association.identifiers)) {
newAssociations.push(association);
}
});
return instance[association.accessors.set](newAssociations, options);
});
};
obj[this.accessors.removeMultiple] = function(oldAssociatedObjects, options) {
var instance = this;
return instance[association.accessors.get]({}, options).then(function(currentAssociatedObjects) {
var newAssociations = [];
// Ensure the oldAssociatedObjects array is an array of target instances
oldAssociatedObjects = oldAssociatedObjects.map(function(oldAssociatedObject) {
if (!(oldAssociatedObject instanceof association.target.Instance)) {
var tmpInstance = {};
tmpInstance[primaryKeyAttribute] = oldAssociatedObject;
oldAssociatedObject = association.target.build(tmpInstance, {
isNewRecord: false
});
}
return oldAssociatedObject;
});
currentAssociatedObjects.forEach(function(association) {
// Determine is this is an association we want to remove
var obj = Utils._.find(oldAssociatedObjects, function(oldAssociatedObject) {
return Utils._.isEqual(oldAssociatedObject.identifiers, association.identifiers);
});
// This is not an association we want to remove. Add it back
// to the set of associations we will associate our instance with
if (!obj) {
newAssociations.push(association);
}
});
return instance[association.accessors.set](newAssociations, options);
});
};
return this;
};
BelongsToMany.prototype.injectCreator = function(obj) {
var association = this;
obj[this.accessors.create] = function(values, options) {
var instance = this;
options = options || {};
if (Array.isArray(options)) {
options = {
fields: options
};
}
if (values === undefined) {
values = {};
}
if (association.scope) {
Object.keys(association.scope).forEach(function (attribute) {
values[attribute] = association.scope[attribute];
if (options.fields) options.fields.push(attribute);
});
}
// 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;
};
return BelongsToMany;
})();
...@@ -6,7 +6,13 @@ var Utils = require('./../utils') ...@@ -6,7 +6,13 @@ var Utils = require('./../utils')
, Association = require('./base') , Association = require('./base')
, Transaction = require('../transaction') , Transaction = require('../transaction')
, Model = require('../model') , Model = require('../model')
, CounterCache = require('../plugins/counter-cache'); , CounterCache = require('../plugins/counter-cache')
, deprecatedSeen = {}
, deprecated = function(message) {
if (deprecatedSeen[message]) return;
console.warn(message);
deprecatedSeen[message] = true;
};
var HasManySingleLinked = require('./has-many-single-linked') var HasManySingleLinked = require('./has-many-single-linked')
, HasManyDoubleLinked = require('./has-many-double-linked'); , HasManyDoubleLinked = require('./has-many-double-linked');
...@@ -203,6 +209,8 @@ module.exports = (function() { ...@@ -203,6 +209,8 @@ module.exports = (function() {
// is there already a single sided association between the source and the target? // is there already a single sided association between the source and the target?
// or is the association on the model itself? // or is the association on the model itself?
if ((this.isSelfAssociation && Object(this.through.model) === this.through.model) || doubleLinked) { if ((this.isSelfAssociation && Object(this.through.model) === this.through.model) || doubleLinked) {
deprecated('Using 2 x hasMany to represent N:M relations has been deprecated. Please use belongsToMany instead');
// We need to remove the keys that 1:M have added // We need to remove the keys that 1:M have added
if (this.isSelfAssociation && doubleLinked) { if (this.isSelfAssociation && doubleLinked) {
if (self.through.model.rawAttributes[this.targetAssociation.identifier] if (self.through.model.rawAttributes[this.targetAssociation.identifier]
......
...@@ -3,6 +3,7 @@ ...@@ -3,6 +3,7 @@
var Utils = require('./../utils') var Utils = require('./../utils')
, HasOne = require('./has-one') , HasOne = require('./has-one')
, HasMany = require('./has-many') , HasMany = require('./has-many')
, BelongsToMany = require('./belongs-to-many')
, BelongsTo = require('./belongs-to'); , BelongsTo = require('./belongs-to');
/** /**
...@@ -266,6 +267,100 @@ Mixin.hasMany = function(targetModel, options) { ...@@ -266,6 +267,100 @@ Mixin.hasMany = function(targetModel, options) {
return association; return association;
}; };
/**
* Create an N:M association with a join table
*
* ```js
* User.belongsToMany(Project)
* Project.belongsToMany(User)
* ```
* By default, the name of the join table will be source+target, so in this case projectsusers. This can be overridden by providing either a string or a Model as `through` in the options.
* The following methods are injected on the source:
*
* * get[AS] - for example getPictures(finder). The finder object is passed to `target.find`.
* * set[AS] - for example setPictures(instances, defaultAttributes|options). Update the associations. All currently associated models that are not in instances will be removed.
* * add[AS] - for example addPicture(instance, defaultAttributes|options). Add another associated object.
* * add[AS] [plural] - for example addPictures([instance1, instance2], defaultAttributes|options). Add some more associated objects.
* * create[AS] - for example createPicture(values, options). Build and save a new association.
* * remove[AS] - for example removePicture(instance). Remove a single association.
* * remove[AS] [plural] - for example removePictures(instance). Remove multiple association.
* * has[AS] - for example hasPicture(instance). Is source associated to this target?
* * has[AS] [plural] - for example hasPictures(instances). Is source associated to all these targets?
*
* All methods return a promise
*
* If you use a through model with custom attributes, these attributes can be set when adding / setting new associations in two ways. Consider users and projects from before
* with a join table that stores whether the project has been started yet:
* ```js
* var UserProjects = sequelize.define('userprojects', {
* started: Sequelize.BOOLEAN
* })
* User.belongsToMany(Project, { through: UserProjects })
* Project.belongsToMany(User, { through: UserProjects })
* ```
* ```js
* jan.addProject(homework, { started: false }) // The homework project is not started yet
* jan.setProjects([makedinner, doshopping], { started: true}) // Both shopping and dinner has been started
* ```
*
* If you want to set several target instances, but with different attributes you have to set the attributes on the instance, using a property with the name of the through model:
*
* ```js
* p1.userprojects {
* started: true
* }
* user.setProjects([p1, p2], {started: false}) // The default value is false, but p1 overrides that.
* ```
*
* Similarily, when fetching through a join table with custom attributes, these attributes will be available as an object with the name of the through model.
* ```js
* user.getProjects().then(function (projects) {
* var p1 = projects[0]
* p1.userprojects.started // Is this project started yet?
* })
* ```
*
* @param {Model} target
* @param {object} [options]
* @param {boolean} [options.hooks=false] Set to true to run before-/afterDestroy hooks when an associated model is deleted because of a cascade. For example if `User.hasOne(Profile, {onDelete: 'cascade', hooks:true})`, the before-/afterDestroy hooks for profile will be called when a user is deleted. Otherwise the profile will be deleted without invoking any hooks
* @param {Model|string|object} [options.through] The name of the table that is used to join source and target in n:m associations. Can also be a sequelize model if you want to define the junction table yourself and add extra attributes to it.
* @param {Model} [options.through.model] The model used to join both sides of the N:M association.
* @param {object} [options.through.scope] A key/value set that will be used for association create and find defaults on the through model. (Remember to add the attributes to the through model)
* @param {boolean} [options.through.unique=true] If true a unique key will be generated from the foreign keys used (might want to turn this off and create specific unique keys when using scopes)
* @param {string|object} [options.as] The alias of this association. If you provide a string, it should be plural, and will be singularized using node.inflection. If you want to control the singular version yourself, provide an object with `plural` and `singular` keys. See also the `name` option passed to `sequelize.define`. If you create multiple associations between the same tables, you should provide an alias to be able to distinguish between them. If you provide an alias when creating the assocition, you should provide the same alias when eager loading and when getting assocated models. Defaults to the pluralized name of target
* @param {string|object} [options.foreignKey] The name of the foreign key in the join table (representing the source model) or an object representing the type definition for the foreign column (see `Sequelize.define` for syntax). When using an object, you can add a `name` property to set the name of the colum. Defaults to the name of source + primary key of source
* @param {string|object} [options.otherKey] The name of the foreign key in the join table (representing the target model) or an object representing the type definition for the other column (see `Sequelize.define` for syntax). When using an object, you can add a `name` property to set the name of the colum. Defaults to the name of target + primary key of target
* @param {object} [options.scope] A key/value set that will be used for association create and find defaults on the target. (sqlite not supported for N:M)
* @param {string} [options.onDelete='SET NULL|CASCADE'] Cascade if this is a n:m, and set null if it is a 1:m
* @param {string} [options.onUpdate='CASCADE']
* @param {boolean} [options.constraints=true] Should on update and on delete constraints be enabled on the foreign key.
*/
Mixin.belongsToMany = function(targetModel, options) {
if (!(targetModel instanceof this.sequelize.Model)) {
throw new Error(this.name + ".belongsToMany called with something that's not an instance of Sequelize.Model");
}
var sourceModel = this;
// Since this is a mixin, we'll need a unique variable name for hooks (since Model will override our hooks option)
options = options || {};
options.hooks = options.hooks === undefined ? false : Boolean(options.hooks);
options.useHooks = options.hooks;
options = Utils._.extend(options, Utils._.omit(sourceModel.options, ['hooks']));
// the id is in the foreign table or in a connecting table
var association = new BelongsToMany(sourceModel, targetModel, options);
sourceModel.associations[association.associationAccessor] = association.injectAttributes();
association.injectGetter(sourceModel.Instance.prototype);
association.injectSetter(sourceModel.Instance.prototype);
association.injectCreator(sourceModel.Instance.prototype);
return association;
};
Mixin.getAssociation = function(target, alias) { Mixin.getAssociation = function(target, alias) {
for (var associationName in this.associations) { for (var associationName in this.associations) {
if (this.associations.hasOwnProperty(associationName)) { if (this.associations.hasOwnProperty(associationName)) {
......
"use strict";
/* jshint camelcase: false, expr: true */
var chai = require('chai')
, expect = chai.expect
, Support = require(__dirname + '/../support')
, DataTypes = require(__dirname + "/../../lib/data-types")
, Sequelize = require('../../index')
, _ = require('lodash')
, moment = require('moment')
, sinon = require('sinon')
, Promise = Sequelize.Promise
, current = Support.sequelize;
chai.config.includeStack = true;
describe(Support.getTestDialectTeaser("BelongsToMany"), function() {
describe("getAssociations", function() {
beforeEach(function() {
var self = this;
this.User = this.sequelize.define('User', { username: DataTypes.STRING });
this.Task = this.sequelize.define('Task', { title: DataTypes.STRING, active: DataTypes.BOOLEAN });
this.User.belongsToMany(this.Task);
this.Task.belongsToMany(this.User);
return this.sequelize.sync({ force: true }).then(function() {
return Promise.all([
self.User.create({ username: 'John'}),
self.Task.create({ title: 'Get rich', active: true}),
self.Task.create({ title: 'Die trying', active: false})
]);
}).spread(function (john, task1, task2) {
self.tasks = [task1, task2];
return john.setTasks([task1, task2]);
});
});
it('does not modify the passed arguments', function () {
return this.User.create({}).bind(this).then(function (user) {
this.options = {};
return user.getTasks(this.options);
}).then(function () {
expect(this.options).to.deep.equal({});
});
});
if (current.dialect.supports.transactions) {
it('supports transactions', function() {
return Support.prepareTransactionTest(this.sequelize).bind({}).then(function(sequelize) {
this.sequelize = sequelize;
this.Article = sequelize.define('Article', { 'title': DataTypes.STRING });
this.Label = sequelize.define('Label', { 'text': DataTypes.STRING });
this.Article.belongsToMany(this.Label);
this.Label.belongsToMany(this.Article);
return sequelize.sync({ force: true });
}).then(function() {
return Promise.all([
this.Article.create({ title: 'foo' }),
this.Label.create({ text: 'bar' }),
this.sequelize.transaction()
]);
}).spread(function (article, label, t) {
this.t = t;
return article.setLabels([ label ], { transaction: t });
}).then(function() {
return this.Article.all({ transaction: this.t });
}).then(function(articles) {
return articles[0].getLabels();
}).then(function(labels) {
expect(labels).to.have.length(0);
return this.Article.all({ transaction: this.t });
}).then(function(articles) {
return articles[0].getLabels({ transaction: this.t });
}).then(function(labels) {
expect(labels).to.have.length(1);
return this.t.rollback();
});
});
}
it('gets all associated objects with all fields', function() {
return this.User.find({where: {username: 'John'}}).then(function (john) {
return john.getTasks();
}).then(function (tasks) {
tasks[0].attributes.forEach(function(attr) {
expect(tasks[0]).to.have.property(attr);
});
});
});
it("gets all associated objects when no options are passed", function() {
return this.User.find({where: {username: 'John'}}).then(function (john) {
return john.getTasks();
}).then(function (tasks) {
expect(tasks).to.have.length(2);
});
});
it("only get objects that fulfill the options", function() {
return this.User.find({where: {username: 'John'}}).then(function (john) {
return john.getTasks({where: {active: true}});
}).then(function (tasks) {
expect(tasks).to.have.length(1);
});
});
it('supports a where not in', function () {
return this.User.find({
where: {
username: 'John'
}
}).then(function (john) {
return john.getTasks({
where: {
title: {
not: ['Get rich']
}
}
});
}).then(function (tasks) {
expect(tasks).to.have.length(1);
});
});
it('supports a where not in on the primary key', function () {
var self = this;
return this.User.find({
where: {
username: 'John'
}
}).then(function (john) {
return john.getTasks({
where: {
id: {
not: [self.tasks[0].get('id')]
}
}
});
}).then(function (tasks) {
expect(tasks).to.have.length(1);
});
});
it("only gets objects that fulfill options with a formatted value", function() {
return this.User.find({where: {username: 'John'}}).then(function (john) {
return john.getTasks({where: ['active = ?', true]});
}).then(function (tasks) {
expect(tasks).to.have.length(1);
});
});
it("get associated objects with an eager load", function() {
return this.User.find({where: {username: 'John'}, include: [ this.Task ]}).then(function (john) {
expect(john.Tasks).to.have.length(2);
});
});
it("get associated objects with an eager load with conditions but not required", function() {
var Label = this.sequelize.define('Label', { 'title': DataTypes.STRING, 'isActive': DataTypes.BOOLEAN })
, Task = this.Task
, User = this.User;
Task.hasMany(Label);
Label.belongsTo(Task);
return Label.sync({force: true}).then(function() {
return User.find({
where: { username: 'John'},
include: [
{ model: Task, required: false, include: [
{ model: Label, required: false, where: { isActive: true } }
]}
]
});
}).then(function (john) {
expect(john.Tasks).to.have.length(2);
});
});
it('should support schemas', function () {
var self = this
, AcmeUser = self.sequelize.define('User', {
username: DataTypes.STRING
}).schema('acme', '_')
, AcmeProject = self.sequelize.define('Project', {
title: DataTypes.STRING,
active: DataTypes.BOOLEAN
}).schema('acme', '_')
, AcmeProjectUsers = self.sequelize.define('ProjectUsers', {
status: DataTypes.STRING,
data: DataTypes.INTEGER
}).schema('acme', '_');
AcmeUser.belongsToMany(AcmeProject, {through: AcmeProjectUsers});
AcmeProject.belongsToMany(AcmeUser, {through: AcmeProjectUsers});
return self.sequelize.dropAllSchemas().then(function() {
return self.sequelize.createSchema('acme');
}).then(function() {
return self.sequelize.sync({force: true});
}).bind({}).then(function() {
return AcmeUser.create();
}).then(function(u) {
this.u = u;
return AcmeProject.create();
}).then(function(p) {
return this.u.addProject(p, { status: 'active', data: 42 });
}).then(function() {
return this.u.getProjects();
}).then(function(projects) {
expect(projects).to.have.length(1);
var project = projects[0];
expect(project.ProjectUsers).to.be.defined;
expect(project.status).not.to.exist;
expect(project.ProjectUsers.status).to.equal('active');
});
});
});
describe('setAssociations', function() {
it("clears associations when passing null to the set-method", function() {
var User = this.sequelize.define('User', { username: DataTypes.STRING })
, Task = this.sequelize.define('Task', { title: DataTypes.STRING });
User.belongsToMany(Task);
Task.belongsToMany(User);
return this.sequelize.sync({ force: true }).then(function() {
return Promise.all([
User.create({ username: 'foo' }),
Task.create({ title: 'task' })
]);
}).bind({}).spread(function (user, task) {
this.task = task;
return task.setUsers([ user ]);
}).then(function() {
return this.task.getUsers();
}).then(function(_users) {
expect(_users).to.have.length(1);
return this.task.setUsers(null);
}).then(function() {
return this.task.getUsers();
}).then(function(_users) {
expect(_users).to.have.length(0);
});
});
it("should be able to set twice with custom primary keys", function () {
var User = this.sequelize.define('User', { uid: { type:DataTypes.INTEGER, primaryKey:true, autoIncrement: true }, username: DataTypes.STRING })
, Task = this.sequelize.define('Task', { tid: { type:DataTypes.INTEGER, primaryKey:true, autoIncrement: true }, title: DataTypes.STRING });
User.belongsToMany(Task);
Task.belongsToMany(User);
return this.sequelize.sync({ force: true }).then(function () {
return Promise.all([
User.create({ username: 'foo' }),
User.create({ username: 'bar' }),
Task.create({ title: 'task' }),
]);
}).bind({}).spread(function (user1, user2, task) {
this.task = task;
this.user1 = user1;
this.user2 = user2;
return task.setUsers([ user1 ]);
}).then(function () {
this.user2.user_has_task = {usertitle: "Something"};
return this.task.setUsers([ this.user1, this.user2 ]);
}).then(function () {
return this.task.getUsers();
}).then(function (_users) {
expect(_users).to.have.length(2);
});
});
it("joins an association with custom primary keys", function() {
var Group = this.sequelize.define('group', {
group_id: {type: DataTypes.INTEGER, primaryKey: true},
name: DataTypes.STRING(64)
})
, Member = this.sequelize.define('member', {
member_id: {type: DataTypes.INTEGER, primaryKey: true},
email: DataTypes.STRING(64)
});
Group.belongsToMany(Member, {through: 'group_members', foreignKey: 'group_id', otherKey: 'member_id'});
Member.belongsToMany(Group, {through: 'group_members', foreignKey: 'member_id', otherKey: 'group_id'});
return this.sequelize.sync({ force: true }).then(function() {
return Promise.all([
Group.create({group_id: 1, name: 'Group1'}),
Member.create({member_id: 10, email: 'team@sequelizejs.com'})
]);
}).spread(function (group, member) {
return group.addMember(member).return(group);
}).then(function(group) {
return group.getMembers();
}).then(function(members) {
expect(members).to.be.instanceof(Array);
expect(members).to.have.length(1);
expect(members[0].member_id).to.equal(10);
expect(members[0].email).to.equal('team@sequelizejs.com');
});
});
it('supports passing the primary key instead of an object', function () {
var User = this.sequelize.define('User', { username: DataTypes.STRING })
, Task = this.sequelize.define('Task', { title: DataTypes.STRING });
User.belongsToMany(Task);
Task.belongsToMany(User);
return this.sequelize.sync({ force :true }).then(function () {
return Promise.all([
User.create({ id: 12 }),
Task.create({ id: 50, title: 'get started' }),
Task.create({ id: 5, title: 'wat' })
]);
}).bind({}).spread(function (user, task1, task2) {
this.user = user;
this.task2 = task2;
return user.addTask(task1.id);
}).then(function () {
return this.user.setTasks([this.task2.id]);
}).then(function () {
return this.user.getTasks();
}).then(function (tasks) {
expect(tasks).to.have.length(1);
expect(tasks[0].title).to.equal('wat');
});
});
});
describe('createAssociations', function() {
it('creates a new associated object', function() {
var User = this.sequelize.define('User', { username: DataTypes.STRING })
, Task = this.sequelize.define('Task', { title: DataTypes.STRING });
User.belongsToMany(Task);
Task.belongsToMany(User);
return this.sequelize.sync({ force: true }).then(function() {
return Task.create({ title: 'task' });
}).bind({}).then(function(task) {
this.task = task;
return task.createUser({ username: 'foo' });
}).then(function(createdUser) {
expect(createdUser.Model).to.equal(User);
expect(createdUser.username).to.equal('foo');
return this.task.getUsers();
}).then(function(_users) {
expect(_users).to.have.length(1);
});
});
if (current.dialect.supports.transactions) {
it('supports transactions', function() {
return Support.prepareTransactionTest(this.sequelize).bind({}).then(function(sequelize) {
this.User = sequelize.define('User', { username: DataTypes.STRING });
this.Task = sequelize.define('Task', { title: DataTypes.STRING });
this.User.belongsToMany(this.Task);
this.Task.belongsToMany(this.User);
this.sequelize = sequelize;
return sequelize.sync({ force: true });
}).then(function() {
return Promise.all([
this.Task.create({ title: 'task' }),
this.sequelize.transaction()
]);
}).spread(function(task, t) {
this.task = task;
this.t = t;
return task.createUser({ username: 'foo' }, { transaction: t });
}).then(function() {
return this.task.getUsers();
}).then(function(users) {
expect(users).to.have.length(0);
return this.task.getUsers({ transaction: this.t });
}).then(function(users) {
expect(users).to.have.length(1);
return this.t.rollback();
});
});
}
it('supports setting through table attributes', function () {
var User = this.sequelize.define('user', {})
, Group = this.sequelize.define('group', {})
, UserGroups = this.sequelize.define('user_groups', {
isAdmin: Sequelize.BOOLEAN
});
User.belongsToMany(Group, { through: UserGroups });
Group.belongsToMany(User, { through: UserGroups });
return this.sequelize.sync({ force: true }).then(function () {
return Group.create({});
}).then(function (group) {
return Promise.join(
group.createUser({ id: 1 }, { isAdmin: true }),
group.createUser({ id: 2 }, { isAdmin: false }),
function () {
return UserGroups.findAll();
}
);
}).then(function (userGroups) {
userGroups.sort(function (a, b) {
return a.userId < b.userId ? - 1 : 1;
});
expect(userGroups[0].userId).to.equal(1);
expect(userGroups[0].isAdmin).to.be.ok;
expect(userGroups[1].userId).to.equal(2);
expect(userGroups[1].isAdmin).not.to.be.ok;
});
});
it('supports using the field parameter', function () {
var User = this.sequelize.define('User', { username: DataTypes.STRING })
, Task = this.sequelize.define('Task', { title: DataTypes.STRING });
User.belongsToMany(Task);
Task.belongsToMany(User);
return this.sequelize.sync({ force: true }).then(function() {
return Task.create({ title: 'task' });
}).bind({}).then(function(task) {
this.task = task;
return task.createUser({ username: 'foo' }, {fields: ['username']});
}).then(function(createdUser) {
expect(createdUser.Model).to.equal(User);
expect(createdUser.username).to.equal('foo');
return this.task.getUsers();
}).then(function(_users) {
expect(_users).to.have.length(1);
});
});
});
describe('addAssociations', function() {
it('supports both single instance and array', function () {
var User = this.sequelize.define('User', { username: DataTypes.STRING })
, Task = this.sequelize.define('Task', { title: DataTypes.STRING });
User.belongsToMany(Task);
Task.belongsToMany(User);
return this.sequelize.sync({ force: true }).then(function () {
return Promise.all([
User.create({ id: 12 }),
Task.create({ id: 50, title: 'get started' }),
Task.create({ id: 52, title: 'get done' }),
]);
}).spread(function (user, task1, task2) {
return Promise.all([
user.addTask(task1),
user.addTask([task2]),
]).return(user);
}).then(function (user) {
return user.getTasks();
}).then(function (tasks) {
expect(tasks).to.have.length(2);
expect(_.find(tasks, function (item) { return item.title === 'get started'; })).to.be.ok;
expect(_.find(tasks, function (item) { return item.title === 'get done'; })).to.be.ok;
});
});
if (current.dialect.supports.transactions) {
it('supports transactions', function() {
return Support.prepareTransactionTest(this.sequelize).bind({}).then(function(sequelize) {
this.User = sequelize.define('User', { username: DataTypes.STRING });
this.Task = sequelize.define('Task', { title: DataTypes.STRING });
this.User.belongsToMany(this.Task);
this.Task.belongsToMany(this.User);
this.sequelize = sequelize;
return sequelize.sync({ force: true });
}).then(function() {
return Promise.all([
this.User.create({ username: 'foo' }),
this.Task.create({ title: 'task' }),
this.sequelize.transaction()
]);
}).spread(function(user, task, t){
this.task = task;
this.user = user;
this.t = t;
return task.addUser(user, { transaction: t });
}).then(function() {
return this.task.hasUser(this.user);
}).then(function(hasUser) {
expect(hasUser).to.be.false;
return this.task.hasUser(this.user, { transaction: this.t });
}).then(function(hasUser) {
expect(hasUser).to.be.true;
return this.t.rollback();
});
});
it('supports transactions when updating a through model', function() {
return Support.prepareTransactionTest(this.sequelize).bind({}).then(function(sequelize) {
this.User = sequelize.define('User', { username: DataTypes.STRING });
this.Task = sequelize.define('Task', { title: DataTypes.STRING });
this.UserTask = sequelize.define('UserTask', {
status: Sequelize.STRING
});
this.User.belongsToMany(this.Task, { through: this.UserTask });
this.Task.belongsToMany(this.User, { through: this.UserTask });
this.sequelize = sequelize;
return sequelize.sync({ force: true });
}).then(function() {
return Promise.all([
this.User.create({ username: 'foo' }),
this.Task.create({ title: 'task' }),
this.sequelize.transaction({ isolationLevel: Sequelize.Transaction.ISOLATION_LEVELS.READ_COMMITTED })
]);
}).spread(function(user, task, t){
this.task = task;
this.user = user;
this.t = t;
return task.addUser(user, { status: 'pending' }); // Create without transaction, so the old value is accesible from outside the transaction
}).then(function() {
return this.task.addUser(this.user, { transaction: this.t, status: 'completed' }); // Add an already exisiting user in a transaction, updating a value in the join table
}).then(function(hasUser) {
return Promise.all([
this.user.getTasks(),
this.user.getTasks({ transaction: this.t })
]);
}).spread(function(tasks, transactionTasks) {
expect(tasks[0].UserTask.status).to.equal('pending');
expect(transactionTasks[0].UserTask.status).to.equal('completed');
return this.t.rollback();
});
});
}
it('supports passing the primary key instead of an object', function () {
var User = this.sequelize.define('User', { username: DataTypes.STRING })
, Task = this.sequelize.define('Task', { title: DataTypes.STRING });
User.belongsToMany(Task);
Task.belongsToMany(User);
return this.sequelize.sync({ force: true }).then(function () {
return Promise.all([
User.create({ id: 12 }),
Task.create({ id: 50, title: 'get started' }),
]);
}).spread(function (user, task) {
return user.addTask(task.id).return(user);
}).then(function (user) {
return user.getTasks();
}).then(function (tasks) {
expect(tasks[0].title).to.equal('get started');
});
});
it('should not pass indexes to the join table',function(){
var User = this.sequelize.define(
'User',
{ username: DataTypes.STRING },
{
indexes: [
{
name: 'username_unique',
unique: true,
method: 'BTREE',
fields: ['username']
}
]
});
var Task = this.sequelize.define(
'Task',
{ title: DataTypes.STRING },
{
indexes: [
{
name: 'title_index',
method: 'BTREE',
fields: ['title']
}
]
});
//create associations
User.belongsToMany(Task);
Task.belongsToMany(User);
return this.sequelize.sync({ force: true });
});
});
describe('addMultipleAssociations', function () {
it('supports both single instance and array', function () {
var User = this.sequelize.define('User', { username: DataTypes.STRING })
, Task = this.sequelize.define('Task', { title: DataTypes.STRING });
User.belongsToMany(Task);
Task.belongsToMany(User);
return this.sequelize.sync({ force: true }).then(function () {
return Promise.all([
User.create({ id: 12 }),
Task.create({ id: 50, title: 'get started' }),
Task.create({ id: 52, title: 'get done' }),
]);
}).spread(function (user, task1, task2) {
return Promise.all([
user.addTasks(task1),
user.addTasks([task2]),
]).return(user);
}).then(function (user) {
return user.getTasks();
}).then(function (tasks) {
expect(tasks).to.have.length(2);
expect(_.find(tasks, function (item) { return item.title === 'get started'; })).to.be.ok;
expect(_.find(tasks, function (item) { return item.title === 'get done'; })).to.be.ok;
});
});
it('adds associations without removing the current ones', function () {
var User = this.sequelize.define('User', { username: DataTypes.STRING })
, Task = this.sequelize.define('Task', { title: DataTypes.STRING });
User.belongsToMany(Task);
Task.belongsToMany(User);
return this.sequelize.sync({ force: true }).then(function() {
return User.bulkCreate([
{ username: 'foo '},
{ username: 'bar '},
{ username: 'baz '}
]).bind({}).then(function () {
return Promise.all([
Task.create({ title: 'task' }),
User.findAll(),
]);
}).spread(function (task, users) {
this.task = task;
this.users = users;
return task.setUsers([users[0]]);
}).then(function () {
return this.task.addUsers([this.users[1], this.users[2]]);
}).then(function () {
return this.task.getUsers();
}).then(function (users) {
expect(users).to.have.length(3);
});
});
});
});
describe('optimizations using bulk create, destroy and update', function () {
beforeEach(function () {
this.User = this.sequelize.define('User', { username: DataTypes.STRING }, {timestamps: false});
this.Task = this.sequelize.define('Task', { title: DataTypes.STRING }, {timestamps: false});
this.User.belongsToMany(this.Task);
this.Task.belongsToMany(this.User);
return this.sequelize.sync({force: true});
});
it('uses one insert into statement', function () {
var self = this
, spy = sinon.spy();
return Promise.all([
this.User.create({ username: 'foo' }),
this.Task.create({ id: 12, title: 'task1' }),
this.Task.create({ id: 15, title: 'task2' }),
]).spread(function(user, task1, task2) {
return user.setTasks([task1, task2]).on('sql', spy);
}).then(function () {
expect(spy.calledTwice).to.be.ok; // Once for SELECT, once for INSERT
});
});
it('uses one delete from statement', function () {
var self = this
, spy = sinon.spy();
return Promise.all([
this.User.create({ username: 'foo' }),
this.Task.create({ title: 'task1' }),
this.Task.create({ title: 'task2' })
]).spread(function (user, task1, task2) {
return user.setTasks([task1, task2]).return(user);
}).then(function (user) {
return user.setTasks(null).on('sql', spy);
}).then(function () {
expect(spy.calledTwice).to.be.ok; // Once for SELECT, once for DELETE
});
});
}); // end optimization using bulk create, destroy and update
describe('join table creation', function () {
beforeEach(function () {
this.User = this.sequelize.define('User',
{ username: DataTypes.STRING },
{ tableName: 'users'}
);
this.Task = this.sequelize.define('Task',
{ title: DataTypes.STRING },
{ tableName: 'tasks' }
);
this.User.belongsToMany(this.Task, { through: 'user_has_tasks' });
this.Task.belongsToMany(this.User, { through: 'user_has_tasks' });
return this.sequelize.sync({ force: true });
});
it('should work with non integer primary keys', function () {
var Beacons = this.sequelize.define('Beacon', {
id: {
primaryKey: true,
type: DataTypes.UUID
},
name: {
type: DataTypes.STRING,
}
});
// Usar not to clash with the beforEach definition
var Users = this.sequelize.define('Usar', {
name: {
type: DataTypes.STRING
}
});
Beacons.belongsToMany(Users);
Users.belongsToMany(Beacons);
return this.sequelize.sync({force: true});
});
it('uses the specified joinTableName or a reasonable default', function() {
for (var associationName in this.User.associations) {
expect(associationName).not.to.equal(this.User.tableName);
expect(associationName).not.to.equal(this.Task.tableName);
var through = this.User.associations[associationName].through.model;
if (typeof through !== 'undefined') {
expect(through.tableName).to.equal(associationName);
}
var tableName = this.User.associations[associationName].options.tableName;
if (typeof tableName !== 'undefined') {
expect(tableName).to.equal(associationName);
}
}
});
it('makes join table non-paranoid by default', function () {
var paranoidSequelize = Support.createSequelizeInstance({
define: {
paranoid: true
}
})
, ParanoidUser = paranoidSequelize.define('ParanoidUser', {})
, ParanoidTask = paranoidSequelize.define('ParanoidTask', {});
ParanoidUser.belongsToMany(ParanoidTask);
ParanoidTask.belongsToMany(ParanoidUser);
expect(ParanoidUser.options.paranoid).to.be.ok;
expect(ParanoidTask.options.paranoid).to.be.ok;
_.forEach(ParanoidUser.associations, function (association) {
expect(association.through.model.options.paranoid).not.to.be.ok;
});
});
});
describe('foreign keys', function () {
it('should correctly generate underscored keys', function () {
var User = this.sequelize.define('User', {
}, {
tableName: 'users',
underscored: true,
timestamps: false
});
var Place = this.sequelize.define('Place', {
//fields
},{
tableName: 'places',
underscored: true,
timestamps: false
});
User.belongsToMany(Place, { through: 'user_places' });
Place.belongsToMany(User, { through: 'user_places' });
var attributes = this.sequelize.model('user_places').rawAttributes;
expect(attributes.place_id).to.be.ok;
expect(attributes.user_id).to.be.ok;
});
});
describe('foreign key with fields specified', function() {
beforeEach(function() {
this.User = this.sequelize.define('User', { name: DataTypes.STRING });
this.Project = this.sequelize.define('Project', { name: DataTypes.STRING });
this.Puppy = this.sequelize.define('Puppy', { breed: DataTypes.STRING });
// doubly linked has many
this.User.belongsToMany(this.Project, {
through: 'user_projects',
as: 'Projects',
foreignKey: {
field: 'user_id',
name: 'userId'
},
otherKey: {
field: 'project_id',
name: 'projectId'
}
});
this.Project.belongsToMany(this.User, {
through: 'user_projects',
as: 'Users',
foreignKey: {
field: 'project_id',
name: 'projectId'
},
otherKey: {
field: 'user_id',
name: 'userId'
}
});
});
it('should correctly get associations even after a child instance is deleted', function() {
var self = this
, user
, projects;
return this.sequelize.sync({force: true}).then(function() {
return Promise.join(
self.User.create({name: 'Matt'}),
self.Project.create({name: 'Good Will Hunting'}),
self.Project.create({name: 'The Departed'})
);
}).spread(function (user, project1, project2) {
return user.addProjects([project1, project2]).return(user);
}).then(function(user) {
return Promise.join(
user,
user.getProjects()
);
}).spread(function(user, projects) {
var project = projects[0];
expect(project).to.be.defined;
return project.destroy().return(user);
}).then(function(user) {
return self.User.find({
where: { id: user.id},
include: [{model: self.Project, as: 'Projects'}]
});
}).then(function(user) {
var projects = user.Projects
, project = projects[0];
expect(project).to.be.defined;
});
});
it('should correctly get associations when doubly linked', function() {
var self = this;
return this.sequelize.sync({force: true}).then(function() {
return Promise.all([
self.User.create({name: 'Matt'}),
self.Project.create({name: 'Good Will Hunting'})
]);
}).spread(function (user, project) {
self.user = user;
self.project = project;
return user.addProject(project).return(user);
}).then(function(user) {
return user.getProjects();
}).then(function(projects) {
var project = projects[0];
expect(project).to.be.defined;
return self.user.removeProject(project).on('sql', function (sql) {
}).return(project);
}).then(function(project) {
return self.user.setProjects([project]);
});
});
it('should be able to handle nested includes properly', function() {
var self = this;
this.Group = this.sequelize.define('Group', { groupName: DataTypes.STRING});
this.Group.belongsToMany(this.User, {
through: 'group_users',
as: 'Users',
foreignKey: {
field: 'group_id',
name: 'groupId'
},
otherKey: {
field: 'user_id',
name: 'userId'
}
});
this.User.belongsToMany(this.Group, {
through: 'group_users',
as: 'Groups',
foreignKey: {
field: 'user_id',
name: 'userId'
},
otherKey: {
field: 'group_id',
name: 'groupId'
}
});
return this.sequelize.sync({force: true}).then(function() {
return Promise.join(
self.Group.create({groupName: 'The Illuminati'}),
self.User.create({name: 'Matt'}),
self.Project.create({name: 'Good Will Hunting'})
);
}).spread(function (group, user, project) {
return user.addProject(project).then(function() {
return group.addUser(user).return(group);
});
}).then(function(group) {
// get the group and include both the users in the group and their project's
return self.Group.findAll({
where: {id: group.id},
include: [
{
model: self.User,
as: 'Users',
include: [
{ model: self.Project, as: 'Projects' }
]
}
]
});
}).then(function(groups) {
var group = groups[0];
expect(group).to.be.defined;
var user = group.Users[0];
expect(user).to.be.defined;
var project = user.Projects[0];
expect(project).to.be.defined;
expect(project.name).to.equal('Good Will Hunting');
});
});
});
describe('primary key handling for join table', function () {
beforeEach(function () {
this.User = this.sequelize.define('User',
{ username: DataTypes.STRING },
{ tableName: 'users'}
);
this.Task = this.sequelize.define('Task',
{ title: DataTypes.STRING },
{ tableName: 'tasks' }
);
});
it('removes the primary key if it was added by sequelize', function () {
this.UserTasks = this.sequelize.define('usertasks', {});
this.User.belongsToMany(this.Task, { through: this.UserTasks });
this.Task.belongsToMany(this.User, { through: this.UserTasks });
expect(Object.keys(this.UserTasks.primaryKeys)).to.deep.equal(['TaskId', 'UserId']);
});
it('keeps the primary key if it was added by the user', function () {
var fk;
this.UserTasks = this.sequelize.define('usertasks', {
id: {
type: Sequelize.INTEGER,
autoincrement: true,
primaryKey: true
}
});
this.UserTasks2 = this.sequelize.define('usertasks2', {
userTasksId: {
type: Sequelize.INTEGER,
autoincrement: true,
primaryKey: true
}
});
this.User.belongsToMany(this.Task, { through: this.UserTasks });
this.Task.belongsToMany(this.User, { through: this.UserTasks });
this.User.belongsToMany(this.Task, { through: this.UserTasks2 });
this.Task.belongsToMany(this.User, { through: this.UserTasks2 });
expect(Object.keys(this.UserTasks.primaryKeys)).to.deep.equal(['id']);
expect(Object.keys(this.UserTasks2.primaryKeys)).to.deep.equal(['userTasksId']);
_.each([this.UserTasks, this.UserTasks2], function (model) {
fk = Object.keys(model.options.uniqueKeys)[0];
expect(model.options.uniqueKeys[fk].fields.sort()).to.deep.equal([ 'TaskId', 'UserId' ]);
});
});
describe('without sync', function() {
beforeEach(function() {
var self = this;
return self.sequelize.queryInterface.createTable('users',{ id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true } , username: DataTypes.STRING, createdAt: DataTypes.DATE, updatedAt: DataTypes.DATE }).then(function() {
return self.sequelize.queryInterface.createTable('tasks',{ id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true }, title: DataTypes.STRING, createdAt: DataTypes.DATE, updatedAt: DataTypes.DATE });
}).then(function() {
return self.sequelize.queryInterface.createTable('users_tasks',{ TaskId: DataTypes.INTEGER, UserId: DataTypes.INTEGER, createdAt: DataTypes.DATE, updatedAt: DataTypes.DATE });
});
});
it('removes all associations', function() {
this.UsersTasks = this.sequelize.define('UsersTasks', {}, { tableName: 'users_tasks' });
this.User.belongsToMany(this.Task, { through: this.UsersTasks });
this.Task.belongsToMany(this.User, { through: this.UsersTasks });
expect(Object.keys(this.UsersTasks.primaryKeys)).to.deep.equal(['TaskId', 'UserId']);
return Promise.all([
this.User.create({username: 'foo'}),
this.Task.create({title: 'foo'})
]).spread(function (user, task) {
return user.addTask(task).return(user);
}).then(function (user) {
return user.setTasks(null);
}).then(function(result) {
expect(result).to.be.ok;
});
});
});
});
describe('through', function () {
beforeEach(function () {
this.User = this.sequelize.define('User', {});
this.Project = this.sequelize.define('Project', {});
this.UserProjects = this.sequelize.define('UserProjects', {
status: DataTypes.STRING,
data: DataTypes.INTEGER
});
this.User.belongsToMany(this.Project, { through: this.UserProjects });
this.Project.belongsToMany(this.User, { through: this.UserProjects });
return this.sequelize.sync();
});
describe('fetching from join table', function () {
it('should contain the data from the join table on .UserProjects a DAO', function () {
return Promise.all([
this.User.create(),
this.Project.create(),
]).spread(function (user, project) {
return user.addProject(project, { status: 'active', data: 42 }).return(user);
}).then(function(user) {
return user.getProjects();
}).then(function(projects) {
var project = projects[0];
expect(project.UserProjects).to.be.defined;
expect(project.status).not.to.exist;
expect(project.UserProjects.status).to.equal('active');
expect(project.UserProjects.data).to.equal(42);
});
});
it('should be able to limit the join table attributes returned', function () {
return Promise.all([
this.User.create(),
this.Project.create(),
]).spread(function (user, project) {
return user.addProject(project, { status: 'active', data: 42 }).return(user);
}).then(function(user) {
return user.getProjects({ joinTableAttributes: ['status']});
}).then(function(projects) {
var project = projects[0];
expect(project.UserProjects).to.be.defined;
expect(project.status).not.to.exist;
expect(project.UserProjects.status).to.equal('active');
expect(project.UserProjects.data).not.to.exist;
});
});
});
describe('inserting in join table', function () {
describe('add', function () {
it('should insert data provided on the object into the join table', function () {
return Promise.all([
this.User.create(),
this.Project.create()
]).bind({ UserProjects: this.UserProjects }).spread(function (u, p) {
this.u = u;
this.p = p;
p.UserProjects = { status: 'active' };
return u.addProject(p);
}).then(function() {
return this.UserProjects.find({ where: { UserId: this.u.id, ProjectId: this.p.id }});
}).then(function (up) {
expect(up.status).to.equal('active');
});
});
it('should insert data provided as a second argument into the join table', function () {
return Promise.all([
this.User.create(),
this.Project.create()
]).bind({ UserProjects: this.UserProjects }).spread(function (u, p) {
this.u = u;
this.p = p;
return u.addProject(p, { status: 'active' });
}).then(function() {
return this.UserProjects.find({ where: { UserId: this.u.id, ProjectId: this.p.id }});
}).then(function (up) {
expect(up.status).to.equal('active');
});
});
it('should be able to add twice (second call result in UPDATE call) without any attributes (and timestamps off) on the through model', function () {
var Worker = this.sequelize.define('Worker', {}, {timestamps: false})
, Task = this.sequelize.define('Task', {}, {timestamps: false})
, WorkerTasks = this.sequelize.define('WorkerTasks', {}, {timestamps: false});
Worker.belongsToMany(Task, { through: WorkerTasks });
Task.belongsToMany(Worker, { through: WorkerTasks });
return this.sequelize.sync().bind({}).then(function() {
return Worker.create({id: 1337});
}).then(function (worker) {
this.worker = worker;
return Task.create({id: 7331});
}).then(function (task) {
return this.worker.addTask(this.task);
}).then(function () {
return this.worker.addTask(this.task);
});
});
it('should be able to add twice (second call result in UPDATE call) with custom primary keys and without any attributes (and timestamps off) on the through model', function () {
var Worker = this.sequelize.define('Worker', {
id: {
type: DataTypes.INTEGER,
allowNull: false,
primaryKey: true,
autoIncrement: true
}
}, {timestamps: false})
, Task = this.sequelize.define('Task', {
id: {
type: DataTypes.INTEGER,
allowNull: false,
primaryKey: true,
autoIncrement: true
}
}, {timestamps: false})
, WorkerTasks = this.sequelize.define('WorkerTasks', {
id: {
type: DataTypes.INTEGER,
allowNull: false,
primaryKey: true,
autoIncrement: true
}
}, {timestamps: false});
Worker.belongsToMany(Task, { through: WorkerTasks });
Task.belongsToMany(Worker, { through: WorkerTasks });
return this.sequelize.sync().bind({}).then(function() {
return Worker.create({id: 1337});
}).then(function (worker) {
this.worker = worker;
return Task.create({id: 7331});
}).then(function (task) {
this.task = task;
return this.worker.addTask(this.task);
}).then(function () {
return this.worker.addTask(this.task);
});
});
});
describe('set', function () {
it('should be able to combine properties on the associated objects, and default values', function () {
var self = this;
return Promise.all([
this.User.create(),
this.Project.bulkCreate([{}, {}]).then(function () {
return self.Project.findAll();
})
]).bind({}).spread(function (user, projects) {
this.user = user;
this.p1 = projects[0];
this.p2 = projects[1];
this.p1.UserProjects = { status: 'inactive' };
return user.setProjects([this.p1, this.p2], { status: 'active' });
}).then(function() {
return Promise.all([
self.UserProjects.find({ where: { UserId: this.user.id, ProjectId: this.p1.id }}),
self.UserProjects.find({ where: { UserId: this.user.id, ProjectId: this.p2.id }})
]);
}).spread(function (up1, up2) {
expect(up1.status).to.equal('inactive');
expect(up2.status).to.equal('active');
});
});
it('should be able to set twice (second call result in UPDATE calls) without any attributes (and timestamps off) on the through model', function () {
var Worker = this.sequelize.define('Worker', {}, {timestamps: false})
, Task = this.sequelize.define('Task', {}, {timestamps: false})
, WorkerTasks = this.sequelize.define('WorkerTasks', {}, {timestamps: false});
Worker.belongsToMany(Task, { through: WorkerTasks });
Task.belongsToMany(Worker, { through: WorkerTasks });
return this.sequelize.sync().then(function() {
return Promise.all([
Worker.create(),
Task.bulkCreate([{}, {}]).then(function () {
return Task.findAll();
})
]);
}).spread(function (worker, tasks) {
return worker.setTasks(tasks).return([worker, tasks]);
}).spread(function (worker, tasks) {
return worker.setTasks(tasks);
});
});
});
});
describe('removing from the join table', function () {
it('should remove a single entry without any attributes (and timestamps off) on the through model', function () {
var Worker = this.sequelize.define('Worker', {}, {timestamps: false})
, Task = this.sequelize.define('Task', {}, {timestamps: false})
, WorkerTasks = this.sequelize.define('WorkerTasks', {}, {timestamps: false});
Worker.belongsToMany(Task, { through: WorkerTasks });
Task.belongsToMany(Worker, { through: WorkerTasks });
// Test setup
return this.sequelize.sync().then(function() {
return Sequelize.Promise.all([
Worker.create({}),
Task.bulkCreate([{}, {}, {}]).then(function () {
return Task.findAll();
})
]);
}).spread(function (worker, tasks) {
// Set all tasks, then remove one task by instance, then remove one task by id, then return all tasks
return worker.setTasks(tasks).then(function () {
return worker.removeTask(tasks[0]);
}).then(function() {
return worker.removeTask(tasks[1].id);
}).then(function () {
return worker.getTasks();
});
}).then(function (tasks) {
expect(tasks.length).to.equal(1);
});
});
it('should remove multiple entries without any attributes (and timestamps off) on the through model', function () {
var Worker = this.sequelize.define('Worker', {}, {timestamps: false})
, Task = this.sequelize.define('Task', {}, {timestamps: false})
, WorkerTasks = this.sequelize.define('WorkerTasks', {}, {timestamps: false});
Worker.belongsToMany(Task, { through: WorkerTasks });
Task.belongsToMany(Worker, { through: WorkerTasks });
// Test setup
return this.sequelize.sync().then(function() {
return Sequelize.Promise.all([
Worker.create({}),
Task.bulkCreate([{}, {}, {}, {}, {}]).then(function () {
return Task.findAll();
})
]);
}).spread(function (worker, tasks) {
// Set all tasks, then remove two tasks by instance, then remove two tasks by id, then return all tasks
return worker.setTasks(tasks).then(function () {
return worker.removeTasks([tasks[0], tasks[1]]);
}).then(function () {
return worker.removeTasks([tasks[2].id, tasks[3].id]);
}).then(function () {
return worker.getTasks();
});
}).then(function (tasks) {
expect(tasks.length).to.equal(1);
});
});
});
});
describe('belongsTo and hasMany at once', function() {
beforeEach(function() {
this.A = this.sequelize.define('a', { name: Sequelize.STRING });
this.B = this.sequelize.define('b', { name: Sequelize.STRING });
});
describe('source belongs to target', function() {
beforeEach(function() {
this.A.belongsTo(this.B, { as: 'relation1' });
this.A.belongsToMany(this.B, { as: 'relation2' });
this.B.belongsToMany(this.A, { as: 'relation2' });
return this.sequelize.sync({ force: true });
});
it('correctly uses bId in A', function() {
var self = this;
var a1 = this.A.build({ name: 'a1' })
, b1 = this.B.build({ name: 'b1' });
return a1
.save()
.then(function() { return b1.save(); })
.then(function() { return a1.setRelation1(b1); })
.then(function() { return self.A.find({ where: { name: 'a1' } }); })
.then(function(a) {
expect(a.relation1Id).to.be.eq(b1.id);
});
});
});
describe('target belongs to source', function() {
beforeEach(function() {
this.B.belongsTo(this.A, { as: 'relation1' });
this.A.belongsToMany(this.B, { as: 'relation2' });
this.B.belongsToMany(this.A, { as: 'relation2' });
return this.sequelize.sync({ force: true });
});
it('correctly uses bId in A', function() {
var self = this;
var a1 = this.A.build({ name: 'a1' })
, b1 = this.B.build({ name: 'b1' });
return a1
.save()
.then(function() { return b1.save(); })
.then(function() { return b1.setRelation1(a1); })
.then(function() { return self.B.find({ where: { name: 'b1' } }); })
.then(function(b) {
expect(b.relation1Id).to.be.eq(a1.id);
});
});
});
});
describe('alias', function () {
it("creates the join table when through is a string", function () {
var self = this
, User = this.sequelize.define('User', {})
, Group = this.sequelize.define('Group', {});
User.belongsToMany(Group, { as: 'MyGroups', through: 'group_user'});
Group.belongsToMany(User, { as: 'MyUsers', through: 'group_user'});
return this.sequelize.sync({force:true}).then(function () {
return self.sequelize.getQueryInterface().showAllTables();
}).then(function (result) {
expect(result.indexOf('group_user')).not.to.equal(-1);
});
});
it("creates the join table when through is a model", function () {
var self = this
, User = this.sequelize.define('User', {})
, Group = this.sequelize.define('Group', {})
, UserGroup = this.sequelize.define('GroupUser', {}, {tableName: 'user_groups'});
User.belongsToMany(Group, { as: 'MyGroups', through: UserGroup});
Group.belongsToMany(User, { as: 'MyUsers', through: UserGroup});
return this.sequelize.sync({force:true}).then(function () {
return self.sequelize.getQueryInterface().showAllTables();
}).then(function (result) {
expect(result.indexOf('user_groups')).not.to.equal(-1);
});
});
it("correctly identifies its counterpart when through is a string", function () {
var User = this.sequelize.define('User', {})
, Group = this.sequelize.define('Group', {});
User.belongsToMany(Group, { as: 'MyGroups', through: 'group_user'});
Group.belongsToMany(User, { as: 'MyUsers', through: 'group_user'});
expect(Group.associations.MyUsers.through.model === User.associations.MyGroups.through.model);
expect(Group.associations.MyUsers.through.model.rawAttributes.UserId).to.exist;
expect(Group.associations.MyUsers.through.model.rawAttributes.GroupId).to.exist;
});
it("correctly identifies its counterpart when through is a model", function () {
var User = this.sequelize.define('User', {})
, Group = this.sequelize.define('Group', {})
, UserGroup = this.sequelize.define('GroupUser', {}, {tableName: 'user_groups'});
User.belongsToMany(Group, { as: 'MyGroups', through: UserGroup});
Group.belongsToMany(User, { as: 'MyUsers', through: UserGroup});
expect(Group.associations.MyUsers.through.model === User.associations.MyGroups.through.model);
expect(Group.associations.MyUsers.through.model.rawAttributes.UserId).to.exist;
expect(Group.associations.MyUsers.through.model.rawAttributes.GroupId).to.exist;
});
});
describe('multiple hasMany', function() {
beforeEach(function() {
this.User = this.sequelize.define('user', { name: Sequelize.STRING });
this.Project = this.sequelize.define('project', { projectName: Sequelize.STRING });
});
describe('project has owners and users and owners and users have projects', function() {
beforeEach(function() {
this.Project.belongsToMany(this.User, { as: 'owners', through: 'projectOwners'});
this.Project.belongsToMany(this.User, { as: 'users', through: 'projectUsers'});
this.User.belongsToMany(this.Project, { as: 'ownedProjects', through: 'projectOwners'});
this.User.belongsToMany(this.Project, { as: 'memberProjects', through: 'projectUsers'});
return this.sequelize.sync({ force: true });
});
it('correctly sets user and owner', function() {
var self = this;
var p1 = this.Project.build({ projectName: 'p1' })
, u1 = this.User.build({ name: 'u1' })
, u2 = this.User.build({ name: 'u2' });
return p1
.save()
.then(function() { return u1.save(); })
.then(function() { return u2.save(); })
.then(function() { return p1.setUsers([u1]); })
.then(function() { return p1.setOwners([u2]); });
});
});
});
describe("Foreign key constraints", function() {
beforeEach(function () {
this.Task = this.sequelize.define('task', { title: DataTypes.STRING });
this.User = this.sequelize.define('user', { username: DataTypes.STRING });
this.UserTasks = this.sequelize.define('tasksusers', { userId: DataTypes.INTEGER, taskId: DataTypes.INTEGER });
});
it("can cascade deletes both ways by default", function () {
var self = this;
this.User.belongsToMany(this.Task);
this.Task.belongsToMany(this.User);
return this.sequelize.sync({ force: true }).bind({}).then(function() {
return Promise.all([
self.User.create({ id: 67, username: 'foo' }),
self.Task.create({ id: 52, title: 'task' }),
self.User.create({ id: 89, username: 'bar' }),
self.Task.create({ id: 42, title: 'kast' }),
]);
}).spread(function (user1, task1, user2, task2) {
this.user1 = user1;
this.task1 = task1;
this.user2 = user2;
this.task2 = task2;
return Promise.all([
user1.setTasks([task1]),
task2.setUsers([user2])
]);
}).then(function () {
return Promise.all([
this.user1.destroy(),
this.task2.destroy()
]);
}).then(function () {
return Promise.all([
self.sequelize.model('tasksusers').findAll({ where: { userId: this.user1.id }}),
self.sequelize.model('tasksusers').findAll({ where: { taskId: this.task2.id }})
]);
}).spread(function (tu1, tu2) {
expect(tu1).to.have.length(0);
expect(tu2).to.have.length(0);
});
});
if (current.dialect.supports.constraints.restrict) {
it("can restrict deletes both ways", function () {
var self = this
, spy = sinon.spy();
this.User.belongsToMany(this.Task, { onDelete: 'RESTRICT'});
this.Task.belongsToMany(this.User, { onDelete: 'RESTRICT'});
return this.sequelize.sync({ force: true }).bind({}).then(function() {
return Promise.all([
self.User.create({ id: 67, username: 'foo' }),
self.Task.create({ id: 52, title: 'task' }),
self.User.create({ id: 89, username: 'bar' }),
self.Task.create({ id: 42, title: 'kast' }),
]);
}).spread(function (user1, task1, user2, task2) {
this.user1 = user1;
this.task1 = task1;
this.user2 = user2;
this.task2 = task2;
return Promise.all([
user1.setTasks([task1]),
task2.setUsers([user2])
]);
}).then(function () {
return Promise.all([
this.user1.destroy().catch(self.sequelize.ForeignKeyConstraintError, spy), // Fails because of RESTRICT constraint
this.task2.destroy().catch(self.sequelize.ForeignKeyConstraintError, spy)
]);
}).then(function () {
expect(spy).to.have.been.calledTwice;
});
});
it("can cascade and restrict deletes", function () {
var spy = sinon.spy()
, self = this;
self.User.belongsToMany(self.Task, { onDelete: 'RESTRICT'});
self.Task.belongsToMany(self.User, { onDelete: 'CASCADE'});
return this.sequelize.sync({ force: true, logging: true }).bind({}).then(function() {
return Sequelize.Promise.join(
self.User.create({ id: 67, username: 'foo' }),
self.Task.create({ id: 52, title: 'task' }),
self.User.create({ id: 89, username: 'bar' }),
self.Task.create({ id: 42, title: 'kast' })
);
}).spread(function (user1, task1, user2, task2) {
this.user1 = user1;
this.task1 = task1;
this.user2 = user2;
this.task2 = task2;
return Sequelize.Promise.join(
user1.setTasks([task1]),
task2.setUsers([user2])
);
}).then(function () {
return Sequelize.Promise.join(
this.user1.destroy().catch(self.sequelize.ForeignKeyConstraintError, spy), // Fails because of RESTRICT constraint
this.task2.destroy()
);
}).then(function () {
expect(spy).to.have.been.calledOnce;
return self.sequelize.model('tasksusers').findAll({ where: { taskId: this.task2.id }});
}).then(function(usertasks) {
// This should not exist because deletes cascade
expect(usertasks).to.have.length(0);
});
});
}
it("should be possible to remove all constraints", function () {
var self = this;
this.User.belongsToMany(this.Task, { constraints: false });
this.Task.belongsToMany(this.User, { constraints: false });
return this.sequelize.sync({ force: true }).bind({}).then(function() {
return Promise.all([
self.User.create({ id: 67, username: 'foo' }),
self.Task.create({ id: 52, title: 'task' }),
self.User.create({ id: 89, username: 'bar' }),
self.Task.create({ id: 42, title: 'kast' }),
]);
}).spread(function (user1, task1, user2, task2) {
this.user1 = user1;
this.task1 = task1;
this.user2 = user2;
this.task2 = task2;
return Promise.all([
user1.setTasks([task1]),
task2.setUsers([user2])
]);
}).then(function () {
return Promise.all([
this.user1.destroy(),
this.task2.destroy()
]);
}).then(function () {
return Promise.all([
self.sequelize.model('tasksusers').findAll({ where: { userId: this.user1.id }}),
self.sequelize.model('tasksusers').findAll({ where: { taskId: this.task2.id }}),
]);
}).spread(function (ut1, ut2) {
expect(ut1).to.have.length(1);
expect(ut2).to.have.length(1);
});
});
});
describe("Association options", function() {
describe('allows the user to provide an attribute definition object as foreignKey', function () {
it('works when taking a column directly from the object', function () {
var Project = this.sequelize.define('project', {
user_id: {
type: Sequelize.INTEGER,
defaultValue: 42
}
})
, User = this.sequelize.define('user', {
uid: {
type: Sequelize.INTEGER,
primaryKey: true
}
});
User.belongsToMany(Project, { foreignKey: Project.rawAttributes.user_id});
expect(Project.rawAttributes.user_id).to.be.defined;
expect(Project.rawAttributes.user_id.references).to.equal(User.getTableName());
expect(Project.rawAttributes.user_id.referencesKey).to.equal('uid');
expect(Project.rawAttributes.user_id.defaultValue).to.equal(42);
});
});
it('should throw an error if foreignKey and as result in a name clash', function () {
var User = this.sequelize.define('user', {
user: Sequelize.INTEGER
});
expect(User.belongsToMany.bind(User, User, { as: 'user' })).to
.throw("Naming collision between attribute 'user' and association 'user' on model user. To remedy this, change either foreignKey or as in your association definition");
});
});
});
...@@ -1498,7 +1498,7 @@ describe(Support.getTestDialectTeaser("HasMany"), function() { ...@@ -1498,7 +1498,7 @@ describe(Support.getTestDialectTeaser("HasMany"), function() {
Beacons.hasMany(Users); Beacons.hasMany(Users);
Users.hasMany(Beacons); Users.hasMany(Beacons);
return this.sequelize.sync({force: true, logging: true}); return this.sequelize.sync({force: true});
}); });
it('uses the specified joinTableName or a reasonable default', function() { it('uses the specified joinTableName or a reasonable default', function() {
......
Markdown is supported
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!