Polymorphic Associations - 多态关联

*注意: 如本指南所述,在 Sequelize 中使用多态关联时应谨慎行事. 不要只是从此处复制粘贴代码,否则你可能会容易出错并在代码中引入错误. 请确保你了解发生了什么.*

概念

一个 多态关联 由使用同一外键发生的两个(或多个)关联组成.

例如,考虑模型 Image, VideoComment. 前两个代表用户可能发布的内容. 我们希望允许将评论放在两者中. 这样,我们立即想到建立以下关联:

  • ImageComment 之间的一对多关联:

    1. Image.hasMany(Comment);
    2. Comment.belongsTo(Image);
  • VideoComment 之间的一对多关联:

    1. Video.hasMany(Comment);
    2. Comment.belongsTo(Video);

但是,以上操作将导致 Sequelize 在 Comment 表上创建两个外键: ImageIdVideoId. 这是不理想的,因为这种结构使评论看起来可以同时附加到一个图像和一个视频上,这是不正确的. 取而代之的是,我们真正想要的是一个多态关联,其中一个 Comment 指向一个 可评论,它是表示 ImageVideo 之一的抽象多态实体.

在继续配置此类关联之前,让我们看看如何使用它:

  1. const image = await Image.create({ url: "https://placekitten.com/408/287" });
  2. const comment = await image.createComment({ content: "Awesome!" });
  3. console.log(comment.commentableId === image.id); // true
  4. // 我们还可以检索与评论关联的可评论类型.
  5. // 下面显示了相关的可注释实例的模型名称.
  6. console.log(comment.commentableType); // "Image"
  7. // 我们可以使用多态方法来检索相关的可评论内容,
  8. // 而不必关心它是图像还是视频.
  9. const associatedCommentable = await comment.getCommentable();
  10. // 在此示例中,`associatedCommentable` 与 `image` 是同一件事:
  11. const isDeepEqual = require('deep-equal');
  12. console.log(isDeepEqual(image, commentable)); // true

配置一对多多态关联

要为上述示例(这是一对多多态关联的示例)设置多态关联,我们需要执行以下步骤:

  • Comment 模型中定义一个名为 commentableType 的字符串字段;
  • Image/VideoComment 之间定义 hasManybelongsTo 关联:
    • 禁用约束(即使用 { constraints: false }),因为同一个外键引用了多个表;
    • 指定适当的 关联作用域;
  • 为了适当地支持延迟加载,请在 Comment 模型上定义一个名为 getCommentable 的新实例方法,该方法在后台调用正确的 mixin 来获取适当的注释对象;
  • 为了正确支持预先加载,请在 Comment 模型上定义一个 afterFind hook,该 hook 将在每个实例中自动填充 commentable 字段;
  • 为了防止预先加载的 bug/错误,你还可以在相同的 afterFind hook 中从 Comment 实例中删除具体字段 imagevideo,仅保留抽象的 commentable 字段可用.

这是一个示例:

  1. // Helper 方法
  2. const uppercaseFirst = str => `${str[0].toUpperCase()}${str.substr(1)}`;
  3. class Image extends Model {}
  4. Image.init({
  5. title: DataTypes.STRING,
  6. url: DataTypes.STRING
  7. }, { sequelize, modelName: 'image' });
  8. class Video extends Model {}
  9. Video.init({
  10. title: DataTypes.STRING,
  11. text: DataTypes.STRING
  12. }, { sequelize, modelName: 'video' });
  13. class Comment extends Model {
  14. getCommentable(options) {
  15. if (!this.commentableType) return Promise.resolve(null);
  16. const mixinMethodName = `get${uppercaseFirst(this.commentableType)}`;
  17. return this[mixinMethodName](options);
  18. }
  19. }
  20. Comment.init({
  21. title: DataTypes.STRING,
  22. commentableId: DataTypes.INTEGER,
  23. commentableType: DataTypes.STRING
  24. }, { sequelize, modelName: 'comment' });
  25. Image.hasMany(Comment, {
  26. foreignKey: 'commentableId',
  27. constraints: false,
  28. scope: {
  29. commentableType: 'image'
  30. }
  31. });
  32. Comment.belongsTo(Image, { foreignKey: 'commentableId', constraints: false });
  33. Video.hasMany(Comment, {
  34. foreignKey: 'commentableId',
  35. constraints: false,
  36. scope: {
  37. commentableType: 'video'
  38. }
  39. });
  40. Comment.belongsTo(Video, { foreignKey: 'commentableId', constraints: false });
  41. Comment.addHook("afterFind", findResult => {
  42. if (!Array.isArray(findResult)) findResult = [findResult];
  43. for (const instance of findResult) {
  44. if (instance.commentableType === "image" && instance.image !== undefined) {
  45. instance.commentable = instance.image;
  46. } else if (instance.commentableType === "video" && instance.video !== undefined) {
  47. instance.commentable = instance.video;
  48. }
  49. // 防止错误:
  50. delete instance.image;
  51. delete instance.dataValues.image;
  52. delete instance.video;
  53. delete instance.dataValues.video;
  54. }
  55. });

由于 commentableId 列引用了多个表(本例中为两个表),因此我们无法向其添加 REFERENCES 约束. 这就是为什么使用 constraints: false 参数的原因.

注意,在上面的代码中:

  • Image -> Comment 关联定义了一个关联作用域: { commentableType: 'image' }
  • Video -> Comment 关联定义了一个关联作用域: { commentableType: 'video' }

使用关联函数时,这些作用域会自动应用(如关联作用域指南中所述). 以下是一些示例及其生成的 SQL 语句:

  • image.getComments():

    1. SELECT "id", "title", "commentableType", "commentableId", "createdAt", "updatedAt"
    2. FROM "comments" AS "comment"
    3. WHERE "comment"."commentableType" = 'image' AND "comment"."commentableId" = 1;

在这里我们可以看到 `comment`.`commentableType` = 'image' 已自动添加到生成的 SQL 的 WHERE 子句中. 这正是我们想要的行为.

  • image.createComment({ title: 'Awesome!' }):

    1. INSERT INTO "comments" (
    2. "id", "title", "commentableType", "commentableId", "createdAt", "updatedAt"
    3. ) VALUES (
    4. DEFAULT, 'Awesome!', 'image', 1,
    5. '2018-04-17 05:36:40.454 +00:00', '2018-04-17 05:36:40.454 +00:00'
    6. ) RETURNING *;
  • image.addComment(comment):

    1. UPDATE "comments"
    2. SET "commentableId"=1, "commentableType"='image', "updatedAt"='2018-04-17 05:38:43.948 +00:00'
    3. WHERE "id" IN (1)

多态延迟加载

Comment 上的 getCommentable 实例方法为延迟加载相关的 commentable 提供了一种抽象 - 无论注释属于 Image 还是 Video,都可以工作.

通过简单地将 commentableType 字符串转换为对正确的 mixin( getImagegetVideo)的调用即可工作.

注意上面的 getCommentable 实现:

  • 不存在关联时返回 null;
  • 允许你将参数对象传递给 getCommentable(options),就像其他任何标准 Sequelize 方法一样. 对于示例,这对于指定 where 条件或 include 条件很有用.

多态预先加载

现在,我们希望对一个(或多个)注释执行关联的可评论对象的多态预先加载. 我们想要实现类似以下的东西:

  1. const comment = await Comment.findOne({
  2. include: [ /* ... */ ]
  3. });
  4. console.log(comment.commentable); // 这是我们的目标

解决的办法是告诉 Sequelize 同时包含图像和视频,以便上面定义的 afterFind hook可以完成工作,并自动向实例对象添加 commentable 字段,以提供所需的抽象.

示例:

  1. const comments = await Comment.findAll({
  2. include: [Image, Video]
  3. });
  4. for (const comment of comments) {
  5. const message = `Found comment #${comment.id} with ${comment.commentableType} commentable:`;
  6. console.log(message, comment.commentable.toJSON());
  7. }

输出:

  1. Found comment #1 with image commentable: { id: 1,
  2. title: 'Meow',
  3. url: 'https://placekitten.com/408/287',
  4. createdAt: 2019-12-26T15:04:53.047Z,
  5. updatedAt: 2019-12-26T15:04:53.047Z }

注意 - 可能无效的 预先/延迟 加载!

注释 Foo,其 commentableId 为 2,而 commentableTypeimage. 然后 Image AVideo X 的 ID 都恰好等于 2.从概念上讲,很明显,Video XFoo 没有关联,因为即使其 ID 为 2,FoocommentableTypeimage,而不是 video. 然而,这种区分仅在 Sequelize 的 getCommentable 和我们在上面创建的 hook 执行的抽象级别上进行.

这意味着如果在上述情况下调用 Comment.findAll({ include: Video }),Video X 将被预先加载到 Foo 中. 幸运的是,我们的 afterFind hook将自动删除它,以帮助防止错误. 你了解发生了什么是非常重要的.

防止此类错误的最好方法是 不惜一切代价直接使用具体的访问器和mixin (例如 .image, .getVideo(), .setImage() 等) ,总是喜欢我们创建的抽象,例如 .getCommentable() and .commentable. 如果由于某种原因确实需要访问预先加载的 .image.video 请确保将其包装在类型检查中,例如 comment.commentableType === 'image'.

配置多对多多态关联

在上面的示例中,我们将模型 ImageVideo 抽象称为 commentables,其中一个 commentable 具有很多注释. 但是,一个给定的注释将属于一个 commentable - 这就是为什么整个情况都是一对多多态关联的原因.

现在,考虑多对多多态关联,而不是考虑注释,我们将考虑标签. 为了方便起见,我们现在将它们称为 taggables,而不是将它们称为 commentables. 一个 taggable 可以具有多个标签,同时一个标签可以放置在多个 taggables 中.

为此设置如下:

  • 明确定义联结模型,将两个外键指定为 tagIdtaggableId(这样,它是 Tagtaggable 抽象概念之间多对多关系的联结模型);
  • 在联结模型中定义一个名为 taggableType 的字符串字段;
  • 定义两个模型之间的 belongsToMany 关联和 标签:
    • 禁用约束 (即, 使用 { constraints: false }), 因为同一个外键引用了多个表;
    • 指定适当的 关联作用域;
  • Tag 模型上定义一个名为 getTaggables 的新实例方法,该方法在后台调用正确的 mixin 来获取适当的 taggables.

实践:

  1. class Tag extends Model {
  2. getTaggables(options) {
  3. const images = await this.getImages(options);
  4. const videos = await this.getVideos(options);
  5. // 在单个 taggables 数组中合并 images 和 videos
  6. return images.concat(videos);
  7. }
  8. }
  9. Tag.init({
  10. name: DataTypes.STRING
  11. }, { sequelize, modelName: 'tag' });
  12. // 在这里,我们明确定义联结模型
  13. class Tag_Taggable extends Model {}
  14. Tag_Taggable.init({
  15. tagId: {
  16. type: DataTypes.INTEGER,
  17. unique: 'tt_unique_constraint'
  18. },
  19. taggableId: {
  20. type: DataTypes.INTEGER,
  21. unique: 'tt_unique_constraint',
  22. references: null
  23. },
  24. taggableType: {
  25. type: DataTypes.STRING,
  26. unique: 'tt_unique_constraint'
  27. }
  28. }, { sequelize, modelName: 'tag_taggable' });
  29. Image.belongsToMany(Tag, {
  30. through: {
  31. model: Tag_Taggable,
  32. unique: false,
  33. scope: {
  34. taggableType: 'image'
  35. }
  36. },
  37. foreignKey: 'taggableId',
  38. constraints: false
  39. });
  40. Tag.belongsToMany(Image, {
  41. through: {
  42. model: Tag_Taggable,
  43. unique: false
  44. },
  45. foreignKey: 'tagId',
  46. constraints: false
  47. });
  48. Video.belongsToMany(Tag, {
  49. through: {
  50. model: Tag_Taggable,
  51. unique: false,
  52. scope: {
  53. taggableType: 'video'
  54. }
  55. },
  56. foreignKey: 'taggableId',
  57. constraints: false
  58. });
  59. Tag.belongsToMany(Video, {
  60. through: {
  61. model: Tag_Taggable,
  62. unique: false
  63. },
  64. foreignKey: 'tagId',
  65. constraints: false
  66. });

constraints: false 参数禁用引用约束,因为 taggableId 列引用了多个表,因此我们无法向其添加 REFERENCES 约束.

注意下面:

  • Image -> Tag 关联定义了一个关联范围: { taggableType: 'image' }
  • Video -> Tag 关联定义了一个关联范围: { taggableType: 'video' }

使用关联函数时,将自动应用这些作用域. 以下是一些示例及其生成的 SQL 语句:

  • image.getTags():

    1. SELECT
    2. `tag`.`id`,
    3. `tag`.`name`,
    4. `tag`.`createdAt`,
    5. `tag`.`updatedAt`,
    6. `tag_taggable`.`tagId` AS `tag_taggable.tagId`,
    7. `tag_taggable`.`taggableId` AS `tag_taggable.taggableId`,
    8. `tag_taggable`.`taggableType` AS `tag_taggable.taggableType`,
    9. `tag_taggable`.`createdAt` AS `tag_taggable.createdAt`,
    10. `tag_taggable`.`updatedAt` AS `tag_taggable.updatedAt`
    11. FROM `tags` AS `tag`
    12. INNER JOIN `tag_taggables` AS `tag_taggable` ON
    13. `tag`.`id` = `tag_taggable`.`tagId` AND
    14. `tag_taggable`.`taggableId` = 1 AND
    15. `tag_taggable`.`taggableType` = 'image';

在这里我们可以看到 `tag_taggable`.`taggableType` = 'image' 已被自动添加到生成的 SQL 的 WHERE 子句中. 这正是我们想要的行为.

  • tag.getTaggables():

    1. SELECT
    2. `image`.`id`,
    3. `image`.`url`,
    4. `image`.`createdAt`,
    5. `image`.`updatedAt`,
    6. `tag_taggable`.`tagId` AS `tag_taggable.tagId`,
    7. `tag_taggable`.`taggableId` AS `tag_taggable.taggableId`,
    8. `tag_taggable`.`taggableType` AS `tag_taggable.taggableType`,
    9. `tag_taggable`.`createdAt` AS `tag_taggable.createdAt`,
    10. `tag_taggable`.`updatedAt` AS `tag_taggable.updatedAt`
    11. FROM `images` AS `image`
    12. INNER JOIN `tag_taggables` AS `tag_taggable` ON
    13. `image`.`id` = `tag_taggable`.`taggableId` AND
    14. `tag_taggable`.`tagId` = 1;
    15. SELECT
    16. `video`.`id`,
    17. `video`.`url`,
    18. `video`.`createdAt`,
    19. `video`.`updatedAt`,
    20. `tag_taggable`.`tagId` AS `tag_taggable.tagId`,
    21. `tag_taggable`.`taggableId` AS `tag_taggable.taggableId`,
    22. `tag_taggable`.`taggableType` AS `tag_taggable.taggableType`,
    23. `tag_taggable`.`createdAt` AS `tag_taggable.createdAt`,
    24. `tag_taggable`.`updatedAt` AS `tag_taggable.updatedAt`
    25. FROM `videos` AS `video`
    26. INNER JOIN `tag_taggables` AS `tag_taggable` ON
    27. `video`.`id` = `tag_taggable`.`taggableId` AND
    28. `tag_taggable`.`tagId` = 1;

请注意,上述 getTaggables() 的实现允许你将选项对象传递给 getCommentable(options),就像其他任何标准 Sequelize 方法一样. 例如,这对于指定条件或包含条件很有用.

在目标模型上应用作用域

在上面的示例中,scope 参数(例如 scope: { taggableType: 'image' })应用于 联结 模型,而不是 目标 模型,因为它是在 through 下使用的参数.

我们还可以在目标模型上应用关联作用域. 我们甚至可以同时进行.

为了说明这一点,请考虑上述示例在标签和可标记之间的扩展,其中每个标签都有一个状态. 这样,为了获取图像的所有待处理标签,我们可以在 ImageTag 之间建立另一个 belognsToMany 关系,这一次在联结模型上应用作用域,在目标模型上应用另一个作用域:

  1. Image.belongsToMany(Tag, {
  2. through: {
  3. model: Tag_Taggable,
  4. unique: false,
  5. scope: {
  6. taggableType: 'image'
  7. }
  8. },
  9. scope: {
  10. status: 'pending'
  11. },
  12. as: 'pendingTags',
  13. foreignKey: 'taggableId',
  14. constraints: false
  15. });

这样,当调用 image.getPendingTags() 时,将生成以下 SQL 查询:

  1. SELECT
  2. `tag`.`id`,
  3. `tag`.`name`,
  4. `tag`.`status`,
  5. `tag`.`createdAt`,
  6. `tag`.`updatedAt`,
  7. `tag_taggable`.`tagId` AS `tag_taggable.tagId`,
  8. `tag_taggable`.`taggableId` AS `tag_taggable.taggableId`,
  9. `tag_taggable`.`taggableType` AS `tag_taggable.taggableType`,
  10. `tag_taggable`.`createdAt` AS `tag_taggable.createdAt`,
  11. `tag_taggable`.`updatedAt` AS `tag_taggable.updatedAt`
  12. FROM `tags` AS `tag`
  13. INNER JOIN `tag_taggables` AS `tag_taggable` ON
  14. `tag`.`id` = `tag_taggable`.`tagId` AND
  15. `tag_taggable`.`taggableId` = 1 AND
  16. `tag_taggable`.`taggableType` = 'image'
  17. WHERE (
  18. `tag`.`status` = 'pending'
  19. );

我们可以看到两个作用域都是自动应用的:

  • `tag_taggable`.`taggableType` = 'image' 被自动添加到 INNER JOIN;
  • `tag`.`status` = 'pending' 被自动添加到外部 where 子句.