メインコンテンツにスキップ

テーブル継承

概要

テーブル継承は、エンティティ間の階層関係をモデル化できるソフトウェア設計パターンです。データベースレベルでテーブル継承を使用すると、JavaScript/TypeScriptアプリケーションでユニオン型を使用したり、複数のモデル間で共通のプロパティセットを共有したりすることもできます。

このページでは、テーブル継承への2つのアプローチを紹介し、Prisma ORMでそれらを使用する方法について説明します。

テーブル継承の一般的なユースケースは、アプリケーションが何らかの種類のコンテンツアクティビティフィードを表示する必要がある場合です。この場合のコンテンツアクティビティは、ビデオまたは記事である可能性があります。例として、以下を仮定しましょう。

  • コンテンツアクティビティは常にidurlを持つ
  • idurlに加えて、ビデオはdurationIntとしてモデル化)も持つ
  • idurlに加えて、記事はbodyStringとしてモデル化)も持つ

ユースケース

ユニオン型

ユニオン型は、TypeScriptの便利な機能であり、開発者はデータモデルの型をより柔軟に扱うことができます。

TypeScriptでは、ユニオン型は次のようになります。

type Activity = Video | Article

現在、Prismaスキーマでユニオン型をモデル化することはできませんが、テーブル継承といくつかの追加の型定義を使用することで、Prisma ORMでユニオン型を使用できます。

複数のモデル間でプロパティを共有する

複数のモデルが特定のプロパティセットを共有する必要があるユースケースがある場合、テーブル継承を使用してこれをモデル化することもできます。

たとえば、上記のVideoモデルとArticleモデルの両方が共有のtitleプロパティを持つ必要がある場合、テーブル継承を使用してこれを実現することもできます。

単純なPrismaスキーマでは、次のようになります。リレーションがどのように機能するかを示すために、Userモデルも追加していることに注意してください。

schema.prisma
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つの場所に格納します。私たちの例では、idurldurationbody列を持つ単一のActivityテーブルが存在します。また、アクティビティビデオまたは記事であるかどうかを示すtype列も使用します。
  • 複数テーブル継承(MTI)複数のテーブルを使用して、異なるエンティティのデータを別々に格納し、外部キーを介してそれらをリンクします。私たちの例では、idurl列を持つActivityテーブル、durationActivityへの外部キーを持つ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[]
}

注意すべき点がいくつかあります。

  • モデル固有のプロパティdurationbodyは、オプション(つまり、?付き)としてマークする必要があります。これは、ビデオを表すActivityテーブルのレコードには、bodyの値があってはならないためです。逆に、記事を表すActivityレコードには、durationを設定することはできません。
  • type識別子列は、各レコードがビデオ項目または記事項目のどちらを表すかを示します。

Prisma Client API

Prisma ORMがデータモデルの型とAPIを生成する方法により、Activity型と、それに属するCRUDクエリ(createupdatedeleteなど)のみが利用可能になります。

ビデオと記事のクエリ

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[]
}

注意すべき点がいくつかあります。

  • ActivityVideo、およびActivityArticleの間には、1対1のリレーションシップが必要です。このリレーションシップは、必要に応じてレコードに関する特定の情報を取得するために使用されます。
  • モデル固有のプロパティdurationbodyは、このアプローチでは必須にすることができます。
  • 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は特定のモデル(上記の例ではArticleVideo)の適切な型定義をすでに提供しますが、STIではこれらをゼロから作成する必要があります。
  • ID / 主キー:MTIでは、レコードには2つのID(親テーブルに1つ、子テーブルに1つ)があり、一致しない場合があります。アプリケーションのビジネスロジックでこれを考慮する必要があります。

サードパーティソリューション

Prisma ORMは現時点ではユニオン型またはポリモーフィズムをネイティブにサポートしていませんが、Prismaスキーマに機能の追加レイヤーを追加するZenstackを確認できます。詳細については、Prisma ORMのポリモーフィズムに関するブログ投稿をお読みください。