Skip to main content
Version: v6 - stable

多态关联

注意:如本指南所述,应谨慎使用 Sequelize 中的多态关联。不要只是从这里复制粘贴代码,否则你可能很容易犯错误并在代码中引入错误。确保你了解发生了什么事。

¥Note: the usage of polymorphic associations in Sequelize, as outlined in this guide, should be done with caution. Don't just copy-paste code from here, otherwise you might easily make mistakes and introduce bugs in your code. Make sure you understand what is going on.

概念

¥Concept

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

¥A polymorphic association consists on two (or more) associations happening with the same foreign key.

例如,考虑型号 ImageVideoComment。前两个代表用户可能发布的内容。我们希望允许在两者中允许注释。这样,我们立即想到建立以下关联:

¥For example, consider the models Image, Video and Comment. The first two represent something that a user might post. We want to allow comments to be placed in both of them. This way, we immediately think of establishing the following associations:

  • ImageComment 之间的一对多关联:

    ¥A One-to-Many association between Image and Comment:

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

    ¥A One-to-Many association between Video and Comment:

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

但是,上述情况会导致 Sequelize 在 Comment 表上创建两个外键:ImageIdVideoId。这并不理想,因为这种结构使得评论看起来可以同时附加到一张图片和一个视频,但事实并非如此。相反,我们真正想要的恰恰是一个多态关联,其中 Comment 指向单个 Commentable,一个代表 ImageVideo 之一的抽象多态实体。

¥However, the above would cause Sequelize to create two foreign keys on the Comment table: ImageId and VideoId. This is not ideal because this structure makes it look like a comment can be attached at the same time to one image and one video, which isn't true. Instead, what we really want here is precisely a polymorphic association, in which a Comment points to a single Commentable, an abstract polymorphic entity that represents one of Image or Video.

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

¥Before proceeding to how to configure such an association, let's see how using it looks like:

const image = await Image.create({ url: 'https://placekitten.com/408/287' });
const comment = await image.createComment({ content: 'Awesome!' });

console.log(comment.commentableId === image.id); // true

// We can also retrieve which type of commentable a comment is associated to.
// The following prints the model name of the associated commentable instance.
console.log(comment.commentableType); // "Image"

// We can use a polymorphic method to retrieve the associated commentable, without
// having to worry whether it's an Image or a Video.
const associatedCommentable = await comment.getCommentable();

// In this example, `associatedCommentable` is the same thing as `image`:
const isDeepEqual = require('deep-equal');
console.log(isDeepEqual(image, commentable)); // true

配置一对多多态关联

¥Configuring a One-to-Many polymorphic association

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

¥To setup the polymorphic association for the example above (which is an example of One-to-Many polymorphic association), we have the following steps:

  • Comment 模型中定义一个名为 commentableType 的字符串字段;

    ¥Define a string field called commentableType in the Comment model;

  • 定义 Image/VideoComment 之间的 hasManybelongsTo 关联:

    ¥Define the hasMany and belongsTo association between Image/Video and Comment:

    • 禁用约束(即使用 { constraints: false }),因为同一个外键引用多个表;

      ¥Disabling constraints (i.e. using { constraints: false }), since the same foreign key is referencing multiple tables;

    • 指定适当的 关联范围

      ¥Specifying the appropriate association scopes;

  • 为了正确支持延迟加载,请在 Comment 模型上定义一个名为 getCommentable 的新实例方法,该方法在幕后调用正确的 mixin 来获取适当的可注释;

    ¥To properly support lazy loading, define a new instance method on the Comment model called getCommentable which calls, under the hood, the correct mixin to fetch the appropriate commentable;

  • 为了正确支持预加载,请在 Comment 模型上定义一个 afterFind 钩子,该钩子会自动填充每个实例中的 commentable 字段;

    ¥To properly support eager loading, define an afterFind hook on the Comment model that automatically populates the commentable field in every instance;

  • 为了防止预加载中的错误/错误,你还可以从同一 afterFind 钩子中的 Comment 实例中删除具体字段 imagevideo,仅保留抽象 commentable 字段可用。

    ¥To prevent bugs/mistakes in eager loading, you can also delete the concrete fields image and video from Comment instances in the same afterFind hook, leaving only the abstract commentable field available.

这是一个例子:

¥Here is an example:

// Helper function
const uppercaseFirst = str => `${str[0].toUpperCase()}${str.substr(1)}`;

class Image extends Model {}
Image.init(
{
title: DataTypes.STRING,
url: DataTypes.STRING,
},
{ sequelize, modelName: 'image' },
);

class Video extends Model {}
Video.init(
{
title: DataTypes.STRING,
text: DataTypes.STRING,
},
{ sequelize, modelName: 'video' },
);

class Comment extends Model {
getCommentable(options) {
if (!this.commentableType) return Promise.resolve(null);
const mixinMethodName = `get${uppercaseFirst(this.commentableType)}`;
return this[mixinMethodName](options);
}
}
Comment.init(
{
title: DataTypes.STRING,
commentableId: DataTypes.INTEGER,
commentableType: DataTypes.STRING,
},
{ sequelize, modelName: 'comment' },
);

Image.hasMany(Comment, {
foreignKey: 'commentableId',
constraints: false,
scope: {
commentableType: 'image',
},
});
Comment.belongsTo(Image, { foreignKey: 'commentableId', constraints: false });

Video.hasMany(Comment, {
foreignKey: 'commentableId',
constraints: false,
scope: {
commentableType: 'video',
},
});
Comment.belongsTo(Video, { foreignKey: 'commentableId', constraints: false });

Comment.addHook('afterFind', findResult => {
if (!Array.isArray(findResult)) findResult = [findResult];
for (const instance of findResult) {
if (instance.commentableType === 'image' && instance.image !== undefined) {
instance.commentable = instance.image;
} else if (instance.commentableType === 'video' && instance.video !== undefined) {
instance.commentable = instance.video;
}
// To prevent mistakes:
delete instance.image;
delete instance.dataValues.image;
delete instance.video;
delete instance.dataValues.video;
}
});

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

¥Since the commentableId column references several tables (two in this case), we cannot add a REFERENCES constraint to it. This is why the constraints: false option was used.

请注意,在上面的代码中:

¥Note that, in the code above:

  • Image -> Comment 关联定义了关联范围:{ commentableType: 'image' }

    ¥The Image -> Comment association defined an association scope: { commentableType: 'image' }

  • Video -> Comment 关联定义了关联范围:{ commentableType: 'video' }

    ¥The Video -> Comment association defined an association scope: { commentableType: 'video' }

使用关联函数时会自动应用这些范围(如 关联范围 指南中所述)。下面是一些示例及其生成的 SQL 语句:

¥These scopes are automatically applied when using the association functions (as explained in the Association Scopes guide). Some examples are below, with their generated SQL statements:

  • image.getComments()

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

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

    ¥Here we can see that `comment`.`commentableType` = 'image' was automatically added to the WHERE clause of the generated SQL. This is exactly the behavior we want.

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

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

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

多态延迟加载

¥Polymorphic lazy loading

Comment 上的 getCommentable 实例方法提供了延迟加载关联可注释项的抽象 - 无论评论属于图片还是视频都有效。

¥The getCommentable instance method on Comment provides an abstraction for lazy loading the associated commentable - working whether the comment belongs to an Image or a Video.

它的工作原理是简单地将 commentableType 字符串转换为对正确 mixin(getImagegetVideo)的调用。

¥It works by simply converting the commentableType string into a call to the correct mixin (either getImage or getVideo).

请注意上面的 getCommentable 实现:

¥Note that the getCommentable implementation above:

  • 当不存在关联时返回 null(这很好);

    ¥Returns null when no association is present (which is good);

  • 允许你将选项对象传递给 getCommentable(options),就像任何其他标准 Sequelize 方法一样。例如,这对于指定 where 条件或包含很有用。

    ¥Allows you to pass an options object to getCommentable(options), just like any other standard Sequelize method. This is useful to specify where-conditions or includes, for example.

多态预加载

¥Polymorphic eager loading

现在,我们想要对一个(或多个)评论的关联可评论内容执行多态预加载。我们想要实现类似于以下想法的东西:

¥Now, we want to perform a polymorphic eager loading of the associated commentables for one (or more) comments. We want to achieve something similar to the following idea:

const comment = await Comment.findOne({
include: [
/* What to put here? */
],
});
console.log(comment.commentable); // This is our goal

解决方案是告诉 Sequelize 同时包含图片和视频,这样我们上面定义的 afterFind 钩子就会完成工作,自动将 commentable 字段添加到实例对象中,提供我们想要的抽象。

¥The solution is to tell Sequelize to include both Images and Videos, so that our afterFind hook defined above will do the work, automatically adding the commentable field to the instance object, providing the abstraction we want.

例如:

¥For example:

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

输出示例:

¥Output example:

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

警告 - 可能无效的预先/延迟加载!

¥Caution - possibly invalid eager/lazy loading!

考虑注释 Foo,其 commentableId 为 2,commentableTypeimage。还要考虑 Image AVideo X 的 id 恰好都等于 2。从概念上讲,很明显,Video XFoo 没有关联,因为尽管其 id 为 2,但 FoocommentableTypeimage,而不是 video。然而,Sequelize 仅在 getCommentable 和我们上面创建的钩子执行的抽象级别上做出这种区别。

¥Consider a comment Foo whose commentableId is 2 and commentableType is image. Consider also that Image A and Video X both happen to have an id equal to 2. Conceptually, it is clear that Video X is not associated to Foo, because even though its id is 2, the commentableType of Foo is image, not video. However, this distinction is made by Sequelize only at the level of the abstractions performed by getCommentable and the hook we created above.

这意味着,如果在上述情况下调用 Comment.findAll({ include: Video })Video X 将被预加载到 Foo 中。值得庆幸的是,我们的 afterFind 钩子会自动删除它,以帮助防止错误,但无论如何,了解发生了什么很重要。

¥This means that if you call Comment.findAll({ include: Video }) in the situation above, Video X will be eager loaded into Foo. Thankfully, our afterFind hook will delete it automatically, to help prevent bugs, but regardless it is important that you understand what is going on.

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

¥The best way to prevent this kind of mistake is to avoid using the concrete accessors and mixins directly at all costs (such as .image, .getVideo(), .setImage(), etc), always preferring the abstractions we created, such as .getCommentable() and .commentable. If you really need to access eager-loaded .image and .video for some reason, make sure you wrap that in a type check such as comment.commentableType === 'image'.

配置多对多多态关联

¥Configuring a Many-to-Many polymorphic association

在上面的例子中,我们将模型 ImageVideo 抽象地称为可评论的,一个可评论的有很多评论。然而,一个给定的评论将属于单个可评论的 - 这就是为什么整个情况是一对多多态关联的原因。

¥In the above example, we had the models Image and Video being abstractly called commentables, with one commentable having many comments. However, one given comment would belong to a single commentable - this is why the whole situation is a One-to-Many polymorphic association.

现在,为了考虑多对多多态关联,我们将考虑标签,而不是考虑注释。为了方便起见,我们现在将它们称为可标记,而不是将图片和视频称为可注释。一个标签可以有多个标签,同时一个标签可以放置在多个标签中。

¥Now, to consider a Many-to-Many polymorphic association, instead of considering comments, we will consider tags. For convenience, instead of calling Image and Video as commentables, we will now call them taggables. One taggable may have several tags, and at the same time one tag can be placed in several taggables.

其设置如下:

¥The setup for this goes as follows:

  • 显式定义联结模型,指定两个外键为 tagIdtaggableId(这样它就是 Tag 和可标记的抽象概念之间的多对多关系的联结模型);

    ¥Define the junction model explicitly, specifying the two foreign keys as tagId and taggableId (this way it is a junction model for a Many-to-Many relationship between Tag and the abstract concept of taggable);

  • 在连接模型中定义一个名为 taggableType 的字符串字段;

    ¥Define a string field called taggableType in the junction model;

  • 定义两个模型之间的 belongsToMany 关联和 Tag

    ¥Define the belongsToMany associations between the two models and Tag:

    • 禁用约束(即使用 { constraints: false }),因为同一个外键引用多个表;

      ¥Disabling constraints (i.e. using { constraints: false }), since the same foreign key is referencing multiple tables;

    • 指定适当的 关联范围

      ¥Specifying the appropriate association scopes;

  • Tag 模型上定义一个名为 getTaggables 的新实例方法,该方法在后台调用正确的 mixin 来获取适当的标记。

    ¥Define a new instance method on the Tag model called getTaggables which calls, under the hood, the correct mixin to fetch the appropriate taggables.

执行:

¥Implementation:

class Tag extends Model {
async getTaggables(options) {
const images = await this.getImages(options);
const videos = await this.getVideos(options);
// Concat images and videos in a single array of taggables
return images.concat(videos);
}
}
Tag.init(
{
name: DataTypes.STRING,
},
{ sequelize, modelName: 'tag' },
);

// Here we define the junction model explicitly
class Tag_Taggable extends Model {}
Tag_Taggable.init(
{
tagId: {
type: DataTypes.INTEGER,
unique: 'tt_unique_constraint',
},
taggableId: {
type: DataTypes.INTEGER,
unique: 'tt_unique_constraint',
references: null,
},
taggableType: {
type: DataTypes.STRING,
unique: 'tt_unique_constraint',
},
},
{ sequelize, modelName: 'tag_taggable' },
);

Image.belongsToMany(Tag, {
through: {
model: Tag_Taggable,
unique: false,
scope: {
taggableType: 'image',
},
},
foreignKey: 'taggableId',
constraints: false,
});
Tag.belongsToMany(Image, {
through: {
model: Tag_Taggable,
unique: false,
},
foreignKey: 'tagId',
constraints: false,
});

Video.belongsToMany(Tag, {
through: {
model: Tag_Taggable,
unique: false,
scope: {
taggableType: 'video',
},
},
foreignKey: 'taggableId',
constraints: false,
});
Tag.belongsToMany(Video, {
through: {
model: Tag_Taggable,
unique: false,
},
foreignKey: 'tagId',
constraints: false,
});

constraints: false 选项禁用引用约束,因为 taggableId 列引用了多个表,我们无法为其添加 REFERENCES 约束。

¥The constraints: false option disables references constraints, as the taggableId column references several tables, we cannot add a REFERENCES constraint to it.

注意:

¥Note that:

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

    ¥The Image -> Tag association defined an association scope: { taggableType: 'image' }

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

    ¥The Video -> Tag association defined an association scope: { taggableType: 'video' }

使用关联函数时会自动应用这些范围。下面是一些示例及其生成的 SQL 语句:

¥These scopes are automatically applied when using the association functions. Some examples are below, with their generated SQL statements:

  • image.getTags()

    SELECT
    `tag`.`id`,
    `tag`.`name`,
    `tag`.`createdAt`,
    `tag`.`updatedAt`,
    `tag_taggable`.`tagId` AS `tag_taggable.tagId`,
    `tag_taggable`.`taggableId` AS `tag_taggable.taggableId`,
    `tag_taggable`.`taggableType` AS `tag_taggable.taggableType`,
    `tag_taggable`.`createdAt` AS `tag_taggable.createdAt`,
    `tag_taggable`.`updatedAt` AS `tag_taggable.updatedAt`
    FROM `tags` AS `tag`
    INNER JOIN `tag_taggables` AS `tag_taggable` ON
    `tag`.`id` = `tag_taggable`.`tagId` AND
    `tag_taggable`.`taggableId` = 1 AND
    `tag_taggable`.`taggableType` = 'image';

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

    ¥Here we can see that `tag_taggable`.`taggableType` = 'image' was automatically added to the WHERE clause of the generated SQL. This is exactly the behavior we want.

  • tag.getTaggables()

    SELECT
    `image`.`id`,
    `image`.`url`,
    `image`.`createdAt`,
    `image`.`updatedAt`,
    `tag_taggable`.`tagId` AS `tag_taggable.tagId`,
    `tag_taggable`.`taggableId` AS `tag_taggable.taggableId`,
    `tag_taggable`.`taggableType` AS `tag_taggable.taggableType`,
    `tag_taggable`.`createdAt` AS `tag_taggable.createdAt`,
    `tag_taggable`.`updatedAt` AS `tag_taggable.updatedAt`
    FROM `images` AS `image`
    INNER JOIN `tag_taggables` AS `tag_taggable` ON
    `image`.`id` = `tag_taggable`.`taggableId` AND
    `tag_taggable`.`tagId` = 1;

    SELECT
    `video`.`id`,
    `video`.`url`,
    `video`.`createdAt`,
    `video`.`updatedAt`,
    `tag_taggable`.`tagId` AS `tag_taggable.tagId`,
    `tag_taggable`.`taggableId` AS `tag_taggable.taggableId`,
    `tag_taggable`.`taggableType` AS `tag_taggable.taggableType`,
    `tag_taggable`.`createdAt` AS `tag_taggable.createdAt`,
    `tag_taggable`.`updatedAt` AS `tag_taggable.updatedAt`
    FROM `videos` AS `video`
    INNER JOIN `tag_taggables` AS `tag_taggable` ON
    `video`.`id` = `tag_taggable`.`taggableId` AND
    `tag_taggable`.`tagId` = 1;

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

¥Note that the above implementation of getTaggables() allows you to pass an options object to getCommentable(options), just like any other standard Sequelize method. This is useful to specify where-conditions or includes, for example.

在目标模型上应用范围

¥Applying scopes on the target model

在上面的示例中,scope 选项(例如 scope: { taggableType: 'image' })应用于直通模型,而不是目标模型,因为它是在 through 选项下使用的。

¥In the example above, the scope options (such as scope: { taggableType: 'image' }) were applied to the through model, not the target model, since it was used under the through option.

我们还可以在目标模型上应用关联范围。我们甚至可以同时做这两件事。

¥We can also apply an association scope on the target model. We can even do both at the same time.

为了说明这一点,请考虑在标签和可标记对象之间扩展上述示例,其中每个标签都有一个状态。这样,为了获取图片的所有待处理标签,我们可以在 ImageTag 之间建立另一个 belongsToMany 关系,这次在直通模型上应用一个范围,在目标模型上应用另一个范围:

¥To illustrate this, consider an extension of the above example between tags and taggables, where each tag has a status. This way, to get all pending tags of an image, we could establish another belongsToMany relationship between Image and Tag, this time applying a scope on the through model and another scope on the target model:

Image.belongsToMany(Tag, {
through: {
model: Tag_Taggable,
unique: false,
scope: {
taggableType: 'image',
},
},
scope: {
status: 'pending',
},
as: 'pendingTags',
foreignKey: 'taggableId',
constraints: false,
});

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

¥This way, when calling image.getPendingTags(), the following SQL query will be generated:

SELECT
`tag`.`id`,
`tag`.`name`,
`tag`.`status`,
`tag`.`createdAt`,
`tag`.`updatedAt`,
`tag_taggable`.`tagId` AS `tag_taggable.tagId`,
`tag_taggable`.`taggableId` AS `tag_taggable.taggableId`,
`tag_taggable`.`taggableType` AS `tag_taggable.taggableType`,
`tag_taggable`.`createdAt` AS `tag_taggable.createdAt`,
`tag_taggable`.`updatedAt` AS `tag_taggable.updatedAt`
FROM `tags` AS `tag`
INNER JOIN `tag_taggables` AS `tag_taggable` ON
`tag`.`id` = `tag_taggable`.`tagId` AND
`tag_taggable`.`taggableId` = 1 AND
`tag_taggable`.`taggableType` = 'image'
WHERE (
`tag`.`status` = 'pending'
);

我们可以看到两个范围都被自动应用:

¥We can see that both scopes were applied automatically:

  • tag_taggable.taggableType = 'image' 自动添加到 INNER JOIN

    ¥ `tag_taggable`.`taggableType` = 'image' was added automatically to the INNER JOIN;

  • tag.status = 'pending' 自动添加到外部 where 子句。

    ¥ `tag`.`status` = 'pending' was added automatically to an outer where clause.