表继承
概览
表继承是一种软件设计模式,允许对实体之间的层次关系进行建模。在数据库层面使用表继承还可以使你的 JavaScript/TypeScript 应用程序中使用联合类型,或在多个模型之间共享一组通用属性。
本页介绍了两种表继承方法,并解释了如何在 Prisma ORM 中使用它们。
表继承的一个常见用例是当应用程序需要显示某种内容活动的订阅源时。在这种情况下,内容活动可以是视频或文章。举个例子,我们假设
- 内容活动始终具有
id
和url
- 除了
id
和url
,视频还有一个duration
(建模为Int
) - 除了
id
和url
,文章还有一个body
(建模为String
)
用例
联合类型
联合类型是 TypeScript 中的一个方便的特性,它允许开发者更灵活地处理数据模型中的类型。
在 TypeScript 中,联合类型如下所示
type Activity = Video | Article
虽然目前无法在 Prisma schema 中建模联合类型,但你可以通过使用表继承和一些额外的类型定义来在 Prisma ORM 中使用它们。
在多个模型之间共享属性
如果你有一个用例,其中多个模型应该共享一组特定的属性,你也可以使用表继承来建模。
例如,如果上面提到的 Video
和 Article
模型都应该有一个共享的 title
属性,你也可以通过表继承来实现这一点。
示例
在一个简单的 Prisma schema 中,这看起来会是这样。请注意,我们还添加了一个 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 多表继承
以下是两种主要表继承方法的快速比较
- 单表继承 (STI):使用一张表在同一位置存储所有不同实体的数据。在我们的例子中,会有一个单一的
Activity
表,其中包含id
、url
以及duration
和body
列。它还使用一个type
列来指示活动是视频还是文章。 - 多表继承 (MTI):使用多张表分别存储不同实体的数据,并通过外键将它们链接起来。在我们的例子中,会有一个
Activity
表,其中包含id
、url
列;一个Video
表,其中包含duration
和指向Activity
的外键;以及一个Article
表,其中包含body
和外键。还有一个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)
使用 Prisma Client 扩展以获得更便捷的 API
你可以使用 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
之间需要一对一关系。当需要时,此关系用于获取有关记录的特定信息。- 使用此方法,模型特有的属性
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)
使用 Prisma Client 扩展以获得更便捷的 API
你可以使用 Prisma Client 扩展为数据库中的表结构创建更便捷的 API。
STI 和 MTI 之间的权衡
- 数据模型:使用 MTI 时,数据模型可能感觉更清晰。使用 STI 时,你最终可能会得到非常宽的行和许多包含
NULL
值的列。 - 性能:MTI 可能会带来性能开销,因为你需要连接父表和子表才能访问模型相关的所有属性。
- 类型定义:使用 Prisma ORM,MTI 已经为特定模型(即,上述示例中的
Article
和Video
)提供了正确的类型定义,而使用 STI 则需要从头开始创建这些定义。 - ID / 主键:使用 MTI,记录有两个 ID(父表一个,子表一个),它们可能不匹配。你需要在应用程序的业务逻辑中考虑这一点。
第三方解决方案
虽然 Prisma ORM 目前不原生支持联合类型或多态性,但你可以查看 Zenstack,它为 Prisma schema 添加了额外功能层。阅读他们的关于 Prisma ORM 中多态性的博客文章以了解更多信息。