テーブル継承
概要
テーブル継承は、エンティティ間の階層的な関係をモデル化するためのソフトウェア設計パターンです。データベースレベルでテーブル継承を使用すると、JavaScript/TypeScriptアプリケーションでユニオン型を使用したり、複数のモデル間で共通のプロパティセットを共有したりすることもできます。
このページでは、テーブル継承の2つのアプローチと、Prisma ORMでの使用方法について説明します。
テーブル継承の一般的なユースケースは、アプリケーションが何らかのコンテンツアクティビティのフィードを表示する必要がある場合です。この場合、コンテンツアクティビティはビデオまたは記事である可能性があります。例として、以下を想定してみましょう。
- コンテンツアクティビティには常に
idとurlがある idとurlに加えて、ビデオにはduration(Intとしてモデル化)もあるidとurlに加えて、記事にはbody(Stringとしてモデル化)もある
ユースケース
ユニオン型
ユニオン型は、開発者がデータモデル内の型をより柔軟に扱えるようにするTypeScriptの便利な機能です。
TypeScriptでは、ユニオン型は次のようになります
type Activity = Video | Article
現在、Prismaスキーマでユニオン型をモデル化することはできませんが、テーブル継承といくつかの追加の型定義を使用することで、Prisma ORMでこれらを使用できます。
複数のモデル間でプロパティを共有する
複数のモデルが特定のプロパティセットを共有する必要があるユースケースがある場合、テーブル継承を使用してこれをモデル化することもできます。
例えば、上記のVideoモデルとArticleモデルの両方が共有のtitleプロパティを持つべき場合、テーブル継承でこれを実現することもできます。
例
簡単なPrismaスキーマでは、次のようになります。リレーションでどのように機能するかを示すために、Userモデルも追加していることに注意してください。
model Video {
id Int @id
url String @unique
duration Int
user User @relation(fields: [userId], references: [id])
userId Int
}
model Article {
id Int @id
url String @unique
body String
user User @relation(fields: [userId], references: [id])
userId Int
}
model User {
id Int @id
name String
videos Video[]
articles Article[]
}
テーブル継承を使用してこれをどのようにモデル化できるかを見てみましょう。
シングルテーブル継承 vs マルチテーブル継承
テーブル継承の2つの主要なアプローチの簡単な比較です
- シングルテーブル継承(STI):単一のテーブルを使用して、すべての異なるエンティティのデータを1か所に保存します。私たちの例では、
id、url、およびdurationとbody列を持つ単一のActivityテーブルが存在します。また、アクティビティがビデオであるか記事であるかを示すtype列も使用します。 - マルチテーブル継承(MTI):複数のテーブルを使用して、異なるエンティティのデータを個別に保存し、外部キーを介してそれらをリンクします。私たちの例では、
id、url列を持つActivityテーブル、durationとActivityへの外部キーを持つVideoテーブル、およびbodyと外部キーを持つArticleテーブルが存在します。また、アクティビティがビデオであるか記事であるかを示す、識別子として機能するtype列もあります。マルチテーブル継承は『委譲された型』とも呼ばれることに注意してください。
両方のアプローチのトレードオフについては以下で学ぶことができます。
シングルテーブル継承(STI)
データモデル
STIを使用すると、上記のシナリオは次のようにモデル化できます
model Activity {
id Int @id // shared
url String @unique // shared
duration Int? // video-only
body String? // article-only
type ActivityType // discriminator
owner User @relation(fields: [ownerId], references: [id])
ownerId Int
}
enum ActivityType {
Video
Article
}
model User {
id Int @id @default(autoincrement())
name String?
activities Activity[]
}
いくつか注意点
- モデル固有のプロパティ
durationとbodyは、オプションとしてマークする必要があります(つまり、?を使用)。これは、ビデオを表すActivityテーブル内のレコードにbodyの値があってはならないためです。逆に、記事を表すActivityレコードには、`duration`が設定されることはありません。 type識別子列は、各レコードがビデオ項目を表すか記事項目を表すかを示します。
Prisma Client API
Prisma ORMがデータモデルの型とAPIを生成する方法により、Activity型とそれに属するCRUDクエリ(create、update、deleteなど)のみが利用可能になります。
ビデオと記事のクエリ
type列でフィルタリングすることにより、ビデオまたは記事のみをクエリできるようになりました。例:
// Query all videos
const videos = await prisma.activity.findMany({
where: { type: 'Video' },
})
// Query all articles
const articles = await prisma.activity.findMany({
where: { type: 'Article' },
})
専用の型を定義する
そのようにビデオや記事をクエリする場合、TypeScriptは引き続きActivity型のみを認識します。これは、videos内のオブジェクトにも(オプションの)bodyが、articles内のオブジェクトにも(オプションの)durationフィールドが存在するため、煩わしい場合があります。
これらのオブジェクトに型安全性を確保したい場合は、専用の型を定義する必要があります。例えば、生成されたActivity型とTypeScriptのOmitユーティリティ型を使用して、プロパティを削除することでこれを行うことができます。
import { Activity } from '@prisma/client'
type Video = Omit<Activity, 'body' | 'type'>
type Article = Omit<Activity, 'duration' | 'type'>
さらに、Activity型のオブジェクトをVideo型およびArticle型に変換するマッピング関数を作成すると役立ちます。
function activityToVideo(activity: Activity): Video {
return {
url: activity.url,
duration: activity.duration ? activity.duration : -1,
ownerId: activity.ownerId,
} as Video
}
function activityToArticle(activity: Activity): Article {
return {
url: activity.url,
body: activity.body ? activity.body : '',
ownerId: activity.ownerId,
} as Article
}
これで、クエリ後にActivityをより具体的な型(つまり、ArticleまたはVideo)に変換できます。
const videoActivities = await prisma.activity.findMany({
where: { type: 'Video' },
})
const videos: Video[] = videoActivities.map(activityToVideo)
より便利なAPIのためのPrisma Client拡張機能の使用
Prisma Client拡張機能を使用して、データベースのテーブル構造により便利なAPIを作成できます。
マルチテーブル継承(MTI)
データモデル
MTIを使用すると、上記のシナリオは次のようにモデル化できます
model Activity {
id Int @id @default(autoincrement())
url String // shared
type ActivityType // discriminator
video Video? // model-specific 1-1 relation
article Article? // model-specific 1-1 relation
owner User @relation(fields: [ownerId], references: [id])
ownerId Int
}
model Video {
id Int @id @default(autoincrement())
duration Int // video-only
activityId Int @unique
activity Activity @relation(fields: [activityId], references: [id])
}
model Article {
id Int @id @default(autoincrement())
body String // article-only
activityId Int @unique
activity Activity @relation(fields: [activityId], references: [id])
}
enum ActivityType {
Video
Article
}
model User {
id Int @id @default(autoincrement())
name String?
activities Activity[]
}
いくつか注意点
ActivityとVideoの間、およびActivityとArticleの間に1対1のリレーションが必要です。このリレーションシップは、必要に応じてレコードに関する特定の情報をフェッチするために使用されます。- このアプローチでは、モデル固有のプロパティ
durationとbodyを必須にすることができます。 type識別子列は、各レコードがビデオ項目を表すか記事項目を表すかを示します。
Prisma Client API
今回は、PrismaClientインスタンスのvideoおよびarticleプロパティを介して、ビデオと記事を直接クエリできます。
ビデオと記事のクエリ
共有プロパティにアクセスしたい場合は、includeを使用してActivityへのリレーションをフェッチする必要があります。
// Query all videos
const videos = await prisma.video.findMany({
include: { activity: true },
})
// Query all articles
const articles = await prisma.article.findMany({
include: { activity: true },
})
必要に応じて、type識別子列でフィルタリングして、逆方向からクエリすることもできます。
// Query all videos
const videoActivities = await prisma.activity.findMany({
where: { type: 'Video' }
include: { video: true }
})
専用の型を定義する
STIと比較して型に関して少し便利ですが、生成された型付けは依然としてすべてのニーズに合わない可能性があります。
Prisma ORMで生成されたVideo型とArticle型をActivity型と組み合わせて、Video型とArticle型を定義する方法を次に示します。これらの組み合わせにより、必要なプロパティを持つ新しい型が作成されます。特定の型ではtype識別子列が不要になったため、それを省略していることにも注意してください。
import {
Video as VideoDB,
Article as ArticleDB,
Activity,
} from '@prisma/client'
type Video = Omit<VideoDB & Activity, 'type'>
type Article = Omit<ArticleDB & Activity, 'type'>
これらの型が定義されたら、上記のクエリから受け取った型を目的のVideo型とArticle型に変換するマッピング関数を定義できます。Video型の例を次に示します。
import { Prisma, Video as VideoDB, Activity } from '@prisma/client'
type Video = Omit<VideoDB & Activity, 'type'>
// Create `VideoWithActivity` typings for the objects returned above
const videoWithActivity = Prisma.validator<Prisma.VideoDefaultArgs>()({
include: { activity: true },
})
type VideoWithActivity = Prisma.VideoGetPayload<typeof videoWithActivity>
// Map to `Video` type
function toVideo(a: VideoWithActivity): Video {
return {
id: a.id,
url: a.activity.url,
ownerId: a.activity.ownerId,
duration: a.duration,
activityId: a.activity.id,
}
}
これで、上記のクエリによって返されたオブジェクトを取得し、toVideoを使用して変換できます。
const videoWithActivities = await prisma.video.findMany({
include: { activity: true },
})
const videos: Video[] = videoWithActivities.map(toVideo)
より便利なAPIのためのPrisma Client拡張機能の使用
Prisma Client拡張機能を使用して、データベースのテーブル構造により便利なAPIを作成できます。
STIとMTIのトレードオフ
- データモデル:MTIの方がデータモデルがすっきりする場合があります。STIでは、非常に幅の広い行になり、多くの列に
NULL値が含まれる可能性があります。 - パフォーマンス:MTIは、モデルに関連するすべてのプロパティにアクセスするために親テーブルと子テーブルを結合する必要があるため、パフォーマンスコストがかかる場合があります。
- 型付け:Prisma ORMでは、MTIは特定のモデル(上記の例の
ArticleとVideoなど)に対して適切な型付けをすでに提供しますが、STIではこれらをゼロから作成する必要があります。 - ID / プライマリキー:MTIでは、レコードは2つのID(親テーブルと子テーブルに1つずつ)を持ち、これらが一致しない場合があります。アプリケーションのビジネスロジックでこれを考慮する必要があります。
サードパーティソリューション
Prisma ORMは現在、ユニオン型やポリモーフィズムをネイティブでサポートしていませんが、Prismaスキーマに機能の追加レイヤーを追加するZenstackをチェックすることができます。詳細については、Prisma ORMにおけるポリモーフィズムに関する彼らのブログ投稿をお読みください。