跳至主要內容
版本:v6 - 穩定版

多型關聯

注意:如本指南所述,在 Sequelize 中使用多型關聯時應謹慎。不要只是從這裡複製貼上程式碼,否則您可能很容易犯錯並在您的程式碼中引入錯誤。請確保您了解正在發生的事情。

概念

多型關聯由兩個(或多個)使用相同外鍵發生的關聯組成。

例如,考慮模型 ImageVideoComment。前兩個表示使用者可能會發布的內容。我們希望允許在兩者中放置評論。這樣,我們立即想到建立以下關聯

  • ImageComment 之間的一對多關聯

    Image.hasMany(Comment);
    Comment.belongsTo(Image);
  • VideoComment 之間的一對多關聯

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

但是,以上情況會導致 Sequelize 在 Comment 表格上建立兩個外鍵:ImageIdVideoId。這並不理想,因為這種結構看起來像是註解可以同時附加到一個圖像和一個影片,這是不正確的。相反,我們真正想要的是精確的多型關聯,其中 Comment 指向單個 Commentable,一個代表 ImageVideo 之一的抽象多型實體。

在繼續說明如何配置這種關聯之前,讓我們先看看如何使用它

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/VideoComment 之間的 hasManybelongsTo 關聯
    • 停用約束(即使用 { constraints: false }),因為同一個外鍵正在參考多個表格;
    • 指定適當的 關聯範圍
  • 為了正確支援延遲載入,在 Comment 模型上定義一個新的實例方法,稱為 getCommentable,它在後端呼叫正確的 mixin 來取得適當的可註解對象;
  • 為了正確支援預先載入,在 Comment 模型上定義一個 afterFind 鉤子,該鉤子會自動在每個實例中填入 commentable 欄位;
  • 為了防止預先載入中的錯誤/錯誤,您也可以在同一個 afterFind 鉤子中從 Comment 實例中刪除具體的欄位 imagevideo,只留下可用的抽象 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: '太棒了!' }):

    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(getImagegetVideo)。

請注意,上面的 getCommentable 實作

  • 當不存在關聯時,傳回 null(這是好的);
  • 允許您將選項物件傳遞至 getCommentable(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 }

警告 - 可能無效的預先/延遲載入!

假設註解 FoocommentableId 為 2,而 commentableTypeimage。也假設 Image AVideo X 的 id 都恰好等於 2。從概念上講,很明顯 Video X 並未與 Foo 關聯,因為即使其 id 為 2,FoocommentableTypeimage,而不是 video。但是,Sequelize 只有在 getCommentable 執行的抽象化和我們上面建立的鉤子的層級才會進行此區分。

這表示如果您在上述情況下呼叫 Comment.findAll({ include: Video })Video X 將會預先載入到 Foo 中。幸好,我們的 afterFind 鉤子會自動刪除它,以協助防止錯誤,但無論如何,重要的是您要了解正在發生的事情。

防止這種錯誤的最佳方法是不惜一切代價避免直接使用具體的存取器和 mixin(例如 .image.getVideo().setImage() 等),始終偏好我們建立的抽象概念,例如 .getCommentable().commentable。如果您真的需要出於某些原因存取預先載入的 .image.video,請確保將其包裝在類型檢查中,例如 comment.commentableType === 'image'

配置多對多多型關聯

在上面的範例中,我們抽象地將模型 ImageVideo 稱為可註解對象,一個可註解對象有多個註解。但是,一個給定的註解會屬於一個單一的可註解對象 - 這就是為什麼整個情況是多對多多型關聯。

現在,為了考慮多對多多型關聯,我們將考慮標籤而不是考慮註解。為方便起見,我們現在將 Image 和 Video 稱為可標記對象,而不是稱為可註解對象。一個可標記對象可能有多個標籤,同時一個標籤可以放在多個可標記對象中。

此設定如下所示

  • 明確定義連接模型,將兩個外鍵指定為 tagIdtaggableId(這樣它就是 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 條件或包含內容等很有用。

在目標模型上套用範圍

在上面的範例中,scope 選項(例如 scope: { taggableType: 'image' })已套用至through模型,而不是target模型,因為它是在 through 選項下使用。

我們也可以在目標模型上套用關聯範圍。我們甚至可以同時執行兩者。

為了說明這一點,請考慮擴展上述標籤和可標籤項之間的範例,其中每個標籤都具有狀態。這樣,為了取得圖片的所有待處理標籤,我們可以在 ImageTag 之間建立另一個 belongsToMany 關聯,這次在關聯模型上套用作用域,並在目標模型上套用另一個作用域。

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 子句。