テーブル継承
概要
テーブル継承は、エンティティ間の階層関係をモデル化できるソフトウェア設計パターンです。データベースレベルでテーブル継承を使用すると、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つ、子テーブルに1つ)があり、一致しない場合があります。アプリケーションのビジネスロジックでこれを考慮する必要があります。
サードパーティソリューション
Prisma ORMは現時点ではユニオン型またはポリモーフィズムをネイティブにサポートしていませんが、Prismaスキーマに機能の追加レイヤーを追加するZenstackを確認できます。詳細については、Prisma ORMのポリモーフィズムに関するブログ投稿をお読みください。