テーブル継承
概要
テーブル継承は、エンティティ間の階層的な関係をモデル化するためのソフトウェア設計パターンです。データベースレベルでテーブル継承を使用すると、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におけるポリモーフィズムに関する彼らのブログ投稿をお読みください。