Advanced M:N Associations - 高级 M:N 关联

阅读本指南之前,请确保已阅读 关联指南.

让我们从 UserProfile 之间的多对多关系示例开始.

  1. const User = sequelize.define('user', {
  2. username: DataTypes.STRING,
  3. points: DataTypes.INTEGER
  4. }, { timestamps: false });
  5. const Profile = sequelize.define('profile', {
  6. name: DataTypes.STRING
  7. }, { timestamps: false });

定义多对多关系的最简单方法是:

  1. User.belongsToMany(Profile, { through: 'User_Profiles' });
  2. Profile.belongsToMany(User, { through: 'User_Profiles' });

通过将字符串传递给上面的 through,我们要求 Sequelize 自动生成名为 User_Profiles 的模型作为 联结表,该模型只有两列: userIdprofileId. 在这两个列上将建立一个复合唯一键.

我们还可以为自己定义一个模型,以用作联结表.

  1. const User_Profile = sequelize.define('User_Profile', {}, { timestamps: false });
  2. User.belongsToMany(Profile, { through: User_Profile });
  3. Profile.belongsToMany(User, { through: User_Profile });

以上具有完全相同的效果. 注意,我们没有在 User_Profile 模型上定义任何属性. 我们将其传递给 belongsToMany 调用的事实告诉 sequelize 自动创建两个属性 userIdprofileId,就像其他关联一样,也会导致 Sequelize 自动向其中一个涉及的模型添加列.

然而,自己定义模型有几个优点. 例如,我们可以在联结表中定义更多列:

  1. const User_Profile = sequelize.define('User_Profile', {
  2. selfGranted: DataTypes.BOOLEAN
  3. }, { timestamps: false });
  4. User.belongsToMany(Profile, { through: User_Profile });
  5. Profile.belongsToMany(User, { through: User_Profile });

这样,我们现在可以在联结表中跟踪额外的信息,即 selfGranted 布尔值. 例如,当调用 user.addProfile() 时,我们可以使用 through 参数传递额外列的值.

示例:

  1. const amidala = User.create({ username: 'p4dm3', points: 1000 });
  2. const queen = Profile.create({ name: 'Queen' });
  3. await amidala.addProfile(queen, { through: { selfGranted: false } });
  4. const result = await User.findOne({
  5. where: { username: 'p4dm3' },
  6. include: Profile
  7. });
  8. console.log(result);

输出:

  1. {
  2. "id": 4,
  3. "username": "p4dm3",
  4. "points": 1000,
  5. "profiles": [
  6. {
  7. "id": 6,
  8. "name": "queen",
  9. "User_Profile": {
  10. "userId": 4,
  11. "profileId": 6,
  12. "selfGranted": false
  13. }
  14. }
  15. ]
  16. }

你也可以在单个 create 调用中创建所有关系.

示例:

  1. const amidala = await User.create({
  2. username: 'p4dm3',
  3. points: 1000,
  4. profiles: [{
  5. name: 'Queen',
  6. User_Profile: {
  7. selfGranted: true
  8. }
  9. }]
  10. }, {
  11. include: Profile
  12. });
  13. const result = await User.findOne({
  14. where: { username: 'p4dm3' },
  15. include: Profile
  16. });
  17. console.log(result);

输出:

  1. {
  2. "id": 1,
  3. "username": "p4dm3",
  4. "points": 1000,
  5. "profiles": [
  6. {
  7. "id": 1,
  8. "name": "Queen",
  9. "User_Profile": {
  10. "selfGranted": true,
  11. "userId": 1,
  12. "profileId": 1
  13. }
  14. }
  15. ]
  16. }

你可能已经注意到 User_Profiles 表中没有 id 字段. 如上所述,它具有复合唯一键. 该复合唯一密钥的名称由 Sequelize 自动选择,但可以使用 uniqueKey 参数进行自定义:

  1. User.belongsToMany(Profile, { through: User_Profiles, uniqueKey: 'my_custom_unique' });

如果需要的话,另一种可能是强制联结表像其他标准表一样具有主键. 为此,只需在模型中定义主键:

  1. const User_Profile = sequelize.define('User_Profile', {
  2. id: {
  3. type: DataTypes.INTEGER,
  4. primaryKey: true,
  5. autoIncrement: true,
  6. allowNull: false
  7. },
  8. selfGranted: DataTypes.BOOLEAN
  9. }, { timestamps: false });
  10. User.belongsToMany(Profile, { through: User_Profile });
  11. Profile.belongsToMany(User, { through: User_Profile });

上面的代码当然仍然会创建两列 userIdprofileId,但是模型不会在其上设置复合唯一键,而是将其 id 列用作主键. 其他一切仍然可以正常工作.

联结表与普通表以及”超级多对多关联”

现在,我们将比较上面显示的最后一个”多对多”设置与通常的”一对多”关系的用法,以便最后得出 超级多对多关系 的概念作为结论.

模型回顾 (有少量重命名)

为了使事情更容易理解,让我们将 User_Profile 模型重命名为 grant. 请注意,所有操作均与以前相同. 我们的模型是:

  1. const User = sequelize.define('user', {
  2. username: DataTypes.STRING,
  3. points: DataTypes.INTEGER
  4. }, { timestamps: false });
  5. const Profile = sequelize.define('profile', {
  6. name: DataTypes.STRING
  7. }, { timestamps: false });
  8. const Grant = sequelize.define('grant', {
  9. id: {
  10. type: DataTypes.INTEGER,
  11. primaryKey: true,
  12. autoIncrement: true,
  13. allowNull: false
  14. },
  15. selfGranted: DataTypes.BOOLEAN
  16. }, { timestamps: false });

我们使用 Grant 模型作为联结表在 UserProfile 之间建立了多对多关系:

  1. User.belongsToMany(Profile, { through: Grant });
  2. Profile.belongsToMany(User, { through: Grant });

这会自动将 userIdprofileId 列添加到 Grant 模型中.

注意: 如上所示,我们选择强制 grant 模型具有单个主键(通常称为 id). 对于 超级多对多关系(即将定义),这是必需的.

改用一对多关系

除了建立上面定义的多对多关系之外,如果我们执行以下操作怎么办?

  1. // 在 User 和 Grant 之间设置一对多关系
  2. User.hasMany(Grant);
  3. Grant.belongsTo(User);
  4. // 在Profile 和 Grant 之间也设置一对多关系
  5. Profile.hasMany(Grant);
  6. Grant.belongsTo(Profile);

结果基本相同! 这是因为 User.hasMany(Grant)Profile.hasMany(Grant) 会分别自动将 userIdprofileId 列添加到 Grant 中.

这表明一个多对多关系与两个一对多关系没有太大区别. 数据库中的表看起来相同.

唯一的区别是你尝试使用 Sequelize 执行预先加载时.

  1. // 使用多对多方法,你可以:
  2. User.findAll({ include: Profile });
  3. Profile.findAll({ include: User });
  4. // However, you can't do:
  5. User.findAll({ include: Grant });
  6. Profile.findAll({ include: Grant });
  7. Grant.findAll({ include: User });
  8. Grant.findAll({ include: Profile });
  9. // 另一方面,通过双重一对多方法,你可以:
  10. User.findAll({ include: Grant });
  11. Profile.findAll({ include: Grant });
  12. Grant.findAll({ include: User });
  13. Grant.findAll({ include: Profile });
  14. // However, you can't do:
  15. User.findAll({ include: Profile });
  16. Profile.findAll({ include: User });
  17. // 尽管你可以使用嵌套 include 来模拟那些,如下所示:
  18. User.findAll({
  19. include: {
  20. model: Grant,
  21. include: Profile
  22. }
  23. }); // 这模拟了 `User.findAll({ include: Profile })`,
  24. // 但是生成的对象结构有些不同.
  25. // 原始结构的格式为 `user.profiles[].grant`,
  26. // 而模拟结构的格式为 `user.grants[].profiles[]`.

两全其美:超级多对多关系

我们可以简单地组合上面显示的两种方法!

  1. // 超级多对多关系
  2. User.belongsToMany(Profile, { through: Grant });
  3. Profile.belongsToMany(User, { through: Grant });
  4. User.hasMany(Grant);
  5. Grant.belongsTo(User);
  6. Profile.hasMany(Grant);
  7. Grant.belongsTo(Profile);

这样,我们可以进行各种预先加载:

  1. // 全部可以使用:
  2. User.findAll({ include: Profile });
  3. Profile.findAll({ include: User });
  4. User.findAll({ include: Grant });
  5. Profile.findAll({ include: Grant });
  6. Grant.findAll({ include: User });
  7. Grant.findAll({ include: Profile });

我们甚至可以执行各种深层嵌套的 include:

  1. User.findAll({
  2. include: [
  3. {
  4. model: Grant,
  5. include: [User, Profile]
  6. },
  7. {
  8. model: Profile,
  9. include: {
  10. model: User,
  11. include: {
  12. model: Grant,
  13. include: [User, Profile]
  14. }
  15. }
  16. }
  17. ]
  18. });

别名和自定义键名

与其他关系类似,可以为多对多关系定义别名.

在继续之前,请回顾关联指南上的 belongsTo 别名示例. 请注意,在这种情况下,定义关联影响 include 完成方式(即传递关联名称)和 Sequelize 为外键选择的名称(在该示例中,leaderId 是在 Ship 模型上创建的) .

为一个 belongsToMany 关联定义一个别名也会影响 include 执行的方式:

  1. Product.belongsToMany(Category, { as: 'groups', through: 'product_categories' });
  2. Category.belongsToMany(Product, { as: 'items', through: 'product_categories' });
  3. // [...]
  4. await Product.findAll({ include: Category }); // 这无法使用
  5. await Product.findAll({ // 通过别名这可以使用
  6. include: {
  7. model: Category,
  8. as: 'groups'
  9. }
  10. });
  11. await Product.findAll({ include: 'groups' }); // 这也可以使用

但是,在此处定义别名与外键名称无关. 联结表中创建的两个外键的名称仍由 Sequelize 基于关联的模型的名称构造. 通过检查上面示例中的穿透表生成的 SQL,可以很容易看出这一点:

  1. CREATE TABLE IF NOT EXISTS `product_categories` (
  2. `createdAt` DATETIME NOT NULL,
  3. `updatedAt` DATETIME NOT NULL,
  4. `productId` INTEGER NOT NULL REFERENCES `products` (`id`) ON DELETE CASCADE ON UPDATE CASCADE,
  5. `categoryId` INTEGER NOT NULL REFERENCES `categories` (`id`) ON DELETE CASCADE ON UPDATE CASCADE,
  6. PRIMARY KEY (`productId`, `categoryId`)
  7. );

我们可以看到外键是 productIdcategoryId. 要更改这些名称,Sequelize 分别接受参数 foreignKeyotherKey(即,foreignKey 定义联结关系中源模型的 key,而 otherKey 定义目标模型中的 key):

  1. Product.belongsToMany(Category, {
  2. through: 'product_categories',
  3. foreignKey: 'objectId', // 替换 `productId`
  4. otherKey: 'typeId' // 替换 `categoryId`
  5. });
  6. Category.belongsToMany(Product, {
  7. through: 'product_categories',
  8. foreignKey: 'typeId', // 替换 `categoryId`
  9. otherKey: 'objectId' // 替换 `productId`
  10. });

生成 SQL:

  1. CREATE TABLE IF NOT EXISTS `product_categories` (
  2. `createdAt` DATETIME NOT NULL,
  3. `updatedAt` DATETIME NOT NULL,
  4. `objectId` INTEGER NOT NULL REFERENCES `products` (`id`) ON DELETE CASCADE ON UPDATE CASCADE,
  5. `typeId` INTEGER NOT NULL REFERENCES `categories` (`id`) ON DELETE CASCADE ON UPDATE CASCADE,
  6. PRIMARY KEY (`objectId`, `typeId`)
  7. );

如上所示,当使用两个 belongsToMany 调用定义多对多关系时(这是标准方式),应在两个调用中适当地提供 foreignKeyotherKey 参数. 如果仅在一个调用中传递这些参数,那么 Sequelize 行为将不可靠.

自参照

Sequelize 直观地支持自参照多对多关系:

  1. Person.belongsToMany(Person, { as: 'Children', through: 'PersonChildren' })
  2. // 这将创建表 PersonChildren,该表存储对象的 ID.

从联结表中指定属性

默认情况下,当预先加载多对多关系时,Sequelize 将以以下结构返回数据(基于本指南中的第一个示例):

  1. // User.findOne({ include: Profile })
  2. {
  3. "id": 4,
  4. "username": "p4dm3",
  5. "points": 1000,
  6. "profiles": [
  7. {
  8. "id": 6,
  9. "name": "queen",
  10. "grant": {
  11. "userId": 4,
  12. "profileId": 6,
  13. "selfGranted": false
  14. }
  15. }
  16. ]
  17. }

注意,外部对象是一个 User,它具有一个名为 profiles 的字段,该字段是 Profile 数组,因此每个 Profile 都带有一个名为 grant 的额外字段,这是一个 Grant 实例.当从多对多关系预先加载时,这是 Sequelize 创建的默认结构.

但是,如果只需要联结表的某些属性,则可以在 attributes 参数中为数组提供所需的属性. 例如,如果只需要穿透表中的 selfGranted 属性:

  1. User.findOne({
  2. include: {
  3. model: Profile,
  4. through: {
  5. attributes: ['selfGranted']
  6. }
  7. }
  8. });

输出:

  1. {
  2. "id": 4,
  3. "username": "p4dm3",
  4. "points": 1000,
  5. "profiles": [
  6. {
  7. "id": 6,
  8. "name": "queen",
  9. "grant": {
  10. "selfGranted": false
  11. }
  12. }
  13. ]
  14. }

如果你根本不想使用嵌套的 grant 字段,请使用 attributes: []

  1. User.findOne({
  2. include: {
  3. model: Profile,
  4. through: {
  5. attributes: []
  6. }
  7. }
  8. });

输出:

  1. {
  2. "id": 4,
  3. "username": "p4dm3",
  4. "points": 1000,
  5. "profiles": [
  6. {
  7. "id": 6,
  8. "name": "queen"
  9. }
  10. ]
  11. }

如果你使用 mixins(例如 user.getProfiles())而不是查找器方法(例如 User.findAll()),则必须使用 joinTableAttributes 参数:

  1. someUser.getProfiles({ joinTableAttributes: ['selfGranted'] });

输出:

  1. [
  2. {
  3. "id": 6,
  4. "name": "queen",
  5. "grant": {
  6. "selfGranted": false
  7. }
  8. }
  9. ]

多对多对多关系及更多

思考你正在尝试为游戏锦标赛建模. 有玩家和团队. 团队玩游戏. 然而,玩家可以在锦标赛中(但不能在比赛中间)更换团队. 因此,给定一个特定的游戏,有某些团队参与该游戏,并且每个团队都有一组玩家(针对该游戏).

因此,我们首先定义三个相关模型:

  1. const Player = sequelize.define('Player', { username: DataTypes.STRING });
  2. const Team = sequelize.define('Team', { name: DataTypes.STRING });
  3. const Game = sequelize.define('Game', { name: DataTypes.INTEGER });

现在的问题是:如何关联它们?

首先,我们注意到:

  • 一个游戏有许多与之相关的团队(正在玩该游戏的团队);
  • 一个团队可能参加了许多比赛.

以上观察表明,我们需要在 Game 和 Team 之间建立多对多关系. 让我们使用本指南前面解释的超级多对多关系:

  1. // Game 与 Team 之间的超级多对多关系
  2. const GameTeam = sequelize.define('GameTeam', {
  3. id: {
  4. type: DataTypes.INTEGER,
  5. primaryKey: true,
  6. autoIncrement: true,
  7. allowNull: false
  8. }
  9. });
  10. Team.belongsToMany(Game, { through: GameTeam });
  11. Game.belongsToMany(Team, { through: GameTeam });
  12. GameTeam.belongsTo(Game);
  13. GameTeam.belongsTo(Team);
  14. Game.hasMany(GameTeam);
  15. Team.hasMany(GameTeam);

关于玩家的部分比较棘手. 我们注意到,组成一个团队的一组球员不仅取决于团队,还取决于正在考虑哪个游戏. 因此,我们不希望玩家与团队之间存在多对多关系. 我们也不希望玩家与游戏之间存在多对多关系. 除了将玩家与任何这些模型相关联之外,我们需要的是玩家与 团队-游戏约束 之类的关联,因为这是一对(团队加游戏)来定义哪些玩家属于那里 .因此,我们正在寻找的正是联结模型GameTeam本身!并且,我们注意到,由于给定的 游戏-团队 指定了许多玩家,而同一位玩家可以参与许多 游戏-团队,因此我们需要玩家之间的多对多关系和GameTeam!

为了提供最大的灵活性,让我们在这里再次使用”超级多对多”关系构造:

  1. // Player 与 GameTeam 之间的超级多对多关系
  2. const PlayerGameTeam = sequelize.define('PlayerGameTeam', {
  3. id: {
  4. type: DataTypes.INTEGER,
  5. primaryKey: true,
  6. autoIncrement: true,
  7. allowNull: false
  8. }
  9. });
  10. Player.belongsToMany(GameTeam, { through: PlayerGameTeam });
  11. GameTeam.belongsToMany(Player, { through: PlayerGameTeam });
  12. PlayerGameTeam.belongsTo(Player);
  13. PlayerGameTeam.belongsTo(GameTeam);
  14. Player.hasMany(PlayerGameTeam);
  15. GameTeam.hasMany(PlayerGameTeam);

上面的关联正是我们想要的. 这是一个完整的可运行示例:

  1. const { Sequelize, Op, Model, DataTypes } = require('sequelize');
  2. const sequelize = new Sequelize('sqlite::memory:', {
  3. define: { timestamps: false } // 在这个例子中只是为了减少混乱
  4. });
  5. const Player = sequelize.define('Player', { username: DataTypes.STRING });
  6. const Team = sequelize.define('Team', { name: DataTypes.STRING });
  7. const Game = sequelize.define('Game', { name: DataTypes.INTEGER });
  8. // 我们在 Game 和 Team 游戏和团队之间应用超级多对多关系
  9. const GameTeam = sequelize.define('GameTeam', {
  10. id: {
  11. type: DataTypes.INTEGER,
  12. primaryKey: true,
  13. autoIncrement: true,
  14. allowNull: false
  15. }
  16. });
  17. Team.belongsToMany(Game, { through: GameTeam });
  18. Game.belongsToMany(Team, { through: GameTeam });
  19. GameTeam.belongsTo(Game);
  20. GameTeam.belongsTo(Team);
  21. Game.hasMany(GameTeam);
  22. Team.hasMany(GameTeam);
  23. // 我们在 Player 和 GameTeam 游戏和团队之间应用超级多对多关系
  24. const PlayerGameTeam = sequelize.define('PlayerGameTeam', {
  25. id: {
  26. type: DataTypes.INTEGER,
  27. primaryKey: true,
  28. autoIncrement: true,
  29. allowNull: false
  30. }
  31. });
  32. Player.belongsToMany(GameTeam, { through: PlayerGameTeam });
  33. GameTeam.belongsToMany(Player, { through: PlayerGameTeam });
  34. PlayerGameTeam.belongsTo(Player);
  35. PlayerGameTeam.belongsTo(GameTeam);
  36. Player.hasMany(PlayerGameTeam);
  37. GameTeam.hasMany(PlayerGameTeam);
  38. (async () => {
  39. await sequelize.sync();
  40. await Player.bulkCreate([
  41. { username: 's0me0ne' },
  42. { username: 'empty' },
  43. { username: 'greenhead' },
  44. { username: 'not_spock' },
  45. { username: 'bowl_of_petunias' }
  46. ]);
  47. await Game.bulkCreate([
  48. { name: 'The Big Clash' },
  49. { name: 'Winter Showdown' },
  50. { name: 'Summer Beatdown' }
  51. ]);
  52. await Team.bulkCreate([
  53. { name: 'The Martians' },
  54. { name: 'The Earthlings' },
  55. { name: 'The Plutonians' }
  56. ]);
  57. // 让我们开始定义哪些球队参加了哪些比赛.
  58. // 这可以通过几种方式来完成,例如在每个游戏上调用`.setTeams`.
  59. // 但是,为简便起见,我们将直接使用 `create` 调用,
  60. // 直接引用我们想要的 ID. 我们知道 ID 是从 1 开始的.
  61. await GameTeam.bulkCreate([
  62. { GameId: 1, TeamId: 1 }, // 该 GameTeam 将获得 id 1
  63. { GameId: 1, TeamId: 2 }, // 该 GameTeam 将获得 id 2
  64. { GameId: 2, TeamId: 1 }, // 该 GameTeam 将获得 id 3
  65. { GameId: 2, TeamId: 3 }, // 该 GameTeam 将获得 id 4
  66. { GameId: 3, TeamId: 2 }, // 该 GameTeam 将获得 id 5
  67. { GameId: 3, TeamId: 3 } // 该 GameTeam 将获得 id 6
  68. ]);
  69. // 现在让我们指定玩家.
  70. // 为简便起见,我们仅在第二场比赛(Winter Showdown)中这样做.
  71. // 比方说,s0me0ne 和 greenhead 效力于 Martians,
  72. // 而 not_spock 和 bowl_of_petunias 效力于 Plutonians:
  73. await PlayerGameTeam.bulkCreate([
  74. // 在 'Winter Showdown' (即 GameTeamIds 3 和 4)中:
  75. { PlayerId: 1, GameTeamId: 3 }, // s0me0ne played for The Martians
  76. { PlayerId: 3, GameTeamId: 3 }, // greenhead played for The Martians
  77. { PlayerId: 4, GameTeamId: 4 }, // not_spock played for The Plutonians
  78. { PlayerId: 5, GameTeamId: 4 } // bowl_of_petunias played for The Plutonians
  79. ]);
  80. // 现在我们可以进行查询!
  81. const game = await Game.findOne({
  82. where: {
  83. name: "Winter Showdown"
  84. },
  85. include: {
  86. model: GameTeam,
  87. include: [
  88. {
  89. model: Player,
  90. through: { attributes: [] } // 隐藏结果中不需要的 `PlayerGameTeam` 嵌套对象
  91. },
  92. Team
  93. ]
  94. }
  95. });
  96. console.log(`Found game: "${game.name}"`);
  97. for (let i = 0; i < game.GameTeams.length; i++) {
  98. const team = game.GameTeams[i].Team;
  99. const players = game.GameTeams[i].Players;
  100. console.log(`- Team "${team.name}" played game "${game.name}" with the following players:`);
  101. console.log(players.map(p => `--- ${p.username}`).join('\n'));
  102. }
  103. })();

输出:

  1. Found game: "Winter Showdown"
  2. - Team "The Martians" played game "Winter Showdown" with the following players:
  3. --- s0me0ne
  4. --- greenhead
  5. - Team "The Plutonians" played game "Winter Showdown" with the following players:
  6. --- not_spock
  7. --- bowl_of_petunias

因此,这就是我们利用超级多对多关系技术在 Sequelize 中实现三个模型之间的 多对多对多 关系的方式!

这个想法可以递归地应用于甚至更复杂的,多对多对……对多 关系(尽管有时查询可能会变慢).