多态关联
注意: 在 Sequelize 中使用多态关联,如本指南中所述,应谨慎操作。不要直接复制粘贴代码,否则你可能会轻易犯错并在代码中引入错误。请确保你了解正在发生的事情。
概念
多态关联是指两个(或多个)关联使用相同的外部键。
例如,考虑模型 Image
、Video
和 Comment
。前两个代表用户可能发布的内容。我们希望允许在两者中都添加评论。这样,我们就会立即想到建立以下关联
-
Image
和Comment
之间的 一对多 关联Image.hasMany(Comment);
Comment.belongsTo(Image); -
Video
和Comment
之间的 一对多 关联Video.hasMany(Comment);
Comment.belongsTo(Video);
但是,以上操作会导致 Sequelize 在 Comment
表中创建两个外部键:ImageId
和 VideoId
。这并不理想,因为这种结构看起来像是一个评论可以同时附加到一张图片和一个视频,但实际上并非如此。相反,我们真正想要的是一个多态关联,其中一个 Comment
指向一个单一的 Commentable,这是一个抽象的多态实体,代表 Image
或 Video
中的一个。
在继续配置这种关联之前,让我们看看使用它看起来是什么样子
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
配置一对多多态关联
为了设置上述示例中的多态关联(这是一个 一对多 多态关联示例),我们有以下步骤
- 在
Comment
模型中定义一个名为commentableType
的字符串字段; - 在
Image
/Video
和Comment
之间定义hasMany
和belongsTo
关联- 禁用约束(即使用
{ constraints: false }
),因为同一个外部键引用了多个表; - 指定适当的 关联作用域;
- 禁用约束(即使用
- 为了正确支持延迟加载,在
Comment
模型上定义一个名为getCommentable
的新实例方法,该方法在幕后调用正确的 mixin 来获取合适的可评论对象; - 为了正确支持预加载,在
Comment
模型上定义一个afterFind
钩子,该钩子会自动填充每个实例中的commentable
字段; - 为了防止预加载中出现错误,你也可以在同一个
afterFind
钩子中删除Comment
实例中的具体字段image
和video
,只保留抽象的commentable
字段。
以下是一个示例
// 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
选项的原因。
请注意,在上面的代码中
- Image -> Comment 关联定义了一个关联作用域:
{ commentableType: 'image' }
- Video -> Comment 关联定义了一个关联作用域:
{ commentableType: 'video' }
这些作用域在使用关联函数时会自动应用(如 关联作用域 指南中所述)。以下是一些示例,以及它们生成的 SQL 语句
-
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
子句中。这正是我们想要的行为。 -
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)
多态延迟加载
Comment
上的 getCommentable
实例方法为延迟加载关联的可评论对象提供了一个抽象 - 无论评论属于 Image 还是 Video,都能正常工作。
它的工作原理是简单地将 commentableType
字符串转换为对正确 mixin(getImage
或 getVideo
)的调用。
请注意,上面的 getCommentable
实现
- 在没有关联时返回
null
(这是好的); - 允许你向
getCommentable(options)
传递一个 options 对象,就像任何其他标准 Sequelize 方法一样。这对于指定 where 条件或包含条件非常有用,例如。
多态预加载
现在,我们希望对一个(或多个)评论进行多态预加载关联的可评论对象。我们希望实现类似以下想法的功能
const comment = await Comment.findOne({
include: [
/* What to put here? */
],
});
console.log(comment.commentable); // This is our goal
解决方案是告诉 Sequelize 包含 Image 和 Video,这样我们上面定义的 afterFind
钩子就能发挥作用,自动将 commentable
字段添加到实例对象中,提供我们想要的抽象。
例如
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());
}
输出示例
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 }
注意 - 可能无效的预加载/延迟加载!
考虑一个评论 Foo
,它的 commentableId
为 2,commentableType
为 image
。还考虑 Image A
和 Video X
恰好都有一个等于 2 的 id。从概念上讲,很明显 Video X
与 Foo
不关联,因为即使它的 id 为 2,Foo
的 commentableType
也是 image
,而不是 video
。但是,Sequelize 仅在 getCommentable
和我们上面创建的钩子执行的抽象级别上进行这种区分。
这意味着,如果你在上述情况下调用 Comment.findAll({ include: Video })
,Video X
将被预加载到 Foo
中。谢天谢地,我们的 afterFind
钩子会自动删除它,以帮助防止错误,但无论如何,重要的是你要了解正在发生的事情。
防止这种错误的最佳方法是尽可能避免直接使用具体访问器和 mixin(例如 .image
、.getVideo()
、.setImage()
等),始终优先考虑我们创建的抽象,例如 .getCommentable()
和 .commentable
。如果你真的需要出于某种原因访问预加载的 .image
和 .video
,请确保在类型检查(例如 comment.commentableType === 'image'
)中包装它。
配置多对多多态关联
在上面的示例中,我们有模型 Image
和 Video
被抽象地称为可评论对象,一个可评论对象可以拥有多个评论。但是,一个给定的评论将属于一个可评论对象 - 这就是整个情况是 一对多 多态关联的原因。
现在,为了考虑多对多多态关联,我们将不考虑评论,而是考虑标签。为了方便起见,我们将不再将 Image 和 Video 称为可评论对象,而是称为可标记对象。一个可标记对象可能拥有多个标签,同时一个标签也可以放在多个可标记对象中。
该设置如下
- 显式定义连接模型,将两个外部键指定为
tagId
和taggableId
(这样它就是Tag
和可标记对象抽象概念之间的 多对多 关系的连接模型); - 在连接模型中定义一个名为
taggableType
的字符串字段; - 定义两个模型和
Tag
之间的belongsToMany
关联- 禁用约束(即使用
{ constraints: false }
),因为同一个外部键引用了多个表; - 指定适当的 关联作用域;
- 禁用约束(即使用
- 在
Tag
模型上定义一个名为getTaggables
的新实例方法,该方法在幕后调用正确的 mixin 来获取合适的可标记对象。
实现
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
约束。
请注意
- Image -> Tag 关联定义了一个关联作用域:
{ taggableType: 'image' }
- Video -> Tag 关联定义了一个关联作用域:
{ taggableType: 'video' }
这些作用域在使用关联函数时会自动应用。以下是一些示例,以及它们生成的 SQL 语句
-
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
子句中。这正是我们想要的行为。 -
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 条件或 includes 很有用,例如。
在目标模型上应用范围
在上面的示例中,scope
选项(例如 scope: { taggableType: 'image' }
)应用于通过模型,而不是目标模型,因为它是在 through
选项下使用的。
我们也可以在目标模型上应用关联范围。我们甚至可以同时进行这两项操作。
为了说明这一点,请考虑标签和可标记对象之间上述示例的扩展,其中每个标签都有一个状态。这样,为了获取图像的所有待处理标签,我们可以建立另一个 belongsToMany
关系,将 Image
和 Tag
关联起来,这次在通过模型上应用一个范围,在目标模型上应用另一个范围。
Image.belongsToMany(Tag, {
through: {
model: Tag_Taggable,
unique: false,
scope: {
taggableType: 'image',
},
},
scope: {
status: 'pending',
},
as: 'pendingTags',
foreignKey: 'taggableId',
constraints: false,
});
这样,当调用 image.getPendingTags()
时,将生成以下 SQL 查询
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'
);
我们可以看到,两个范围都被自动应用了
`tag_taggable`.`taggableType` = 'image'
被自动添加到INNER JOIN
中;`tag`.`status` = 'pending'
被自动添加到外部 where 子句中。