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

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()...
......
...@@ -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)) {
......
...@@ -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!