多形アソシエーション
注記: このガイドで説明されているSequelizeにおける多形アソシエーションの使用は、注意して行う必要があります。コードをそのままコピー&ペーストしないでください。そうすると、簡単にミスをしてコードにバグを導入する可能性があります。何が起こっているのかを理解してから行ってください。
概念
多形アソシエーションとは、同じ外部キーを使用して2つ(またはそれ以上)のアソシエーションが行われるものです。
例えば、Image
、Video
、Comment
というモデルを考えてみましょう。最初の2つは、ユーザーが投稿する可能性のあるものを表しています。これら両方にコメントを付けることを許可したいと考えています。このように、すぐに以下のアソシエーションを確立することを考えます。
-
Image
とComment
間の1対多アソシエーションImage.hasMany(Comment);
Comment.belongsTo(Image); -
Video
とComment
間の1対多アソシエーションVideo.hasMany(Comment);
Comment.belongsTo(Video);
しかし、上記の方法では、SequelizeによってComment
テーブルに2つの外部キー(ImageId
とVideoId
)が作成されます。これは理想的ではありません。この構造では、コメントが同時に1つの画像と1つのビデオの両方に添付できるよう見えますが、実際にはそうではありません。代わりに、ここで本当に必要なのは、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
1対多の多形アソシエーションの構成
上記(1対多の多形アソシエーションの例)の例に対する多形アソシエーションを設定するには、次の手順を実行します。
Comment
モデルにcommentableType
という文字列フィールドを定義します。Image
/Video
とComment
間のhasMany
とbelongsTo
アソシエーションを定義します。- 同じ外部キーが複数のテーブルを参照しているので、制約を無効にします(つまり、
{ constraints: false }
を使用します)。 - 適切なアソシエーションスコープを指定します。
- 同じ外部キーが複数のテーブルを参照しているので、制約を無効にします(つまり、
- 遅延読み込みを適切にサポートするには、適切なmixinを取得するために内部で正しいmixinを呼び出す
getCommentable
という新しいインスタンスメソッドをComment
モデルに定義します。 - 先行読み込みを適切にサポートするには、すべてのインスタンスで
commentable
フィールドを自動的に設定するafterFind
フックをComment
モデルに定義します。 - 先行読み込みのバグ/ミスを防ぐために、同じ
afterFind
フックで、具体的なフィールドimage
とvideo
をCommentインスタンスから削除し、抽象的な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
列は複数のテーブルを参照しているので(この場合は2つ)、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
インスタンスメソッドは、関連付けられたcommentableの遅延読み込みのための抽象化を提供します。コメントがImageに属しているかVideoに属しているかに関係なく動作します。
これは、単にcommentableType
文字列を正しいmixin(getImage
またはgetVideo
のいずれか)への呼び出しに変換することで機能します。
上記のgetCommentable
の実装では、
- 関連付けが存在しない場合に
null
を返します(これは望ましい動作です)。 - 他の標準的なSequelizeメソッドと同様に、
getCommentable(options)
にオプションオブジェクトを渡すことができます。これは、例えば、where条件やincludeを指定するのに便利です。
多形先行読み込み
次に、1つ以上のコメントについて、関連付けられたcommentableの多形先行読み込みを実行したいと考えています。次のアイデアに似たものを実現したいと考えています。
const comment = await Comment.findOne({
include: [
/* What to put here? */
],
});
console.log(comment.commentable); // This is our goal
解決策は、SequelizeにImageとVideoの両方をincludeするように指示し、上記の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 }
注意 - 有効でない可能性のある先行/遅延読み込み!
commentableId
が2でcommentableType
がimage
であるコメントFoo
があるとします。また、Image A
とVideo X
の両方のidが2であるとします。概念的には、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
を抽象的にcommentableと呼び、1つのcommentableが多くのコメントを持つとしていました。しかし、1つのコメントは単一のcommentableに属します。そのため、全体が1対多の多形アソシエーションになります。
ここで、多対多の多形アソシエーションを考えるために、コメントではなくタグを検討します。便宜上、ImageとVideoをcommentableと呼ぶ代わりに、taggableと呼ぶことにします。1つのtaggableは複数のタグを持つことができ、同時に1つのタグは複数のtaggableに配置できます。
この設定は次のようになります。
- 2つの外部キーを
tagId
とtaggableId
として指定して、結合モデルを明示的に定義します(このようにして、Tag
とtaggableという抽象的な概念間の多対多の関係のための結合モデルになります)。 - 結合モデルに
taggableType
という文字列フィールドを定義します。 - 2つのモデルと
Tag
間のbelongsToMany
アソシエーションを定義します。- 同じ外部キーが複数のテーブルを参照しているので、制約を無効にします(つまり、
{ constraints: false }
を使用します)。 - 適切なアソシエーションスコープを指定します。
- 同じ外部キーが複数のテーブルを参照しているので、制約を無効にします(つまり、
- 内部で適切なtaggableを取得するために、適切なmixinを呼び出す
getTaggables
という新しいインスタンスメソッドをTag
モデルに定義します。
実装
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()
の実装では、他の標準的なSequelizeメソッドと同様に、getCommentable(options)
にオプションオブジェクトを渡すことができます。これは、例えば、where条件やincludeを指定するのに便利です。
ターゲットモデルへのスコープの適用
上記の例では、scope
オプション(例:scope: { taggableType: 'image' }
)は、through
オプションで使用されていたため、ターゲットモデルではなく、スルーモデルに適用されました。
ターゲットモデルにアソシエーションスコープを適用することもできます。両方同時に実行することもできます。
これを説明するために、タグとタグ付け可能なもの間の上記の例を拡張し、各タグにステータスを持たせることを考えてみましょう。このようにすると、画像の保留中のタグをすべて取得するために、Image
とTag
の間に別の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句に自動的に追加されました。