跳到主要内容

表继承

概述

表继承是一种软件设计模式,允许对实体之间的层级关系进行建模。在数据库层面使用表继承也可以让你在 JavaScript/TypeScript 应用中使用联合类型,或在多个模型之间共享一组通用属性。

本页介绍两种表继承方法,并解释如何在 Prisma ORM 中使用它们。

一个常见的表继承用例可能是当应用需要显示某种“内容活动”的“动态”时。在这种情况下,内容活动可以是“视频”或“文章”。举个例子,假设:

  • 内容活动始终具有 idurl
  • 除了 idurl,视频还具有 duration(建模为 Int
  • 除了 idurl,文章还具有 body(建模为 String

用例

联合类型

联合类型是 TypeScript 中一个方便的特性,它允许开发者更灵活地处理数据模型中的类型。

在 TypeScript 中,联合类型看起来如下所示:

type Activity = Video | Article

虽然目前无法在 Prisma schema 中建模联合类型,但你可以通过使用表继承和一些额外的类型定义来与 Prisma ORM 一起使用它们。

在多个模型之间共享属性

如果你有一个用例,其中多个模型应共享一组特定的属性,你也可以使用表继承来建模。

例如,如果上述的 VideoArticle 模型都应该有一个共享的 title 属性,你也可以通过表继承实现这一点。

示例

在一个简单的 Prisma schema 中,它看起来如下所示。请注意,我们也添加了一个 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 多表继承

以下是两种主要表继承方法的快速比较:

  • 单表继承 (STI):使用单个表来存储所有不同实体的数据。在我们的示例中,会有一个单独的 Activity 表,包含 idurl 以及 durationbody 列。它还使用一个 type 列来指示一个活动视频还是文章
  • 多表继承 (MTI):使用多个表分别存储不同实体的数据,并通过外键进行链接。在我们的示例中,会有一个包含 idurl 列的 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[]
}

需要注意的几点:

  • 模型特定的属性 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 类型的对象转换为 VideoArticle 类型会很有帮助:

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 转换为更具体的类型(即 ArticleVideo):

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

需要注意的几点:

  • ActivityVideo 以及 ActivityArticle 之间需要 1 对 1 关系。此关系用于在需要时获取记录的特定信息。
  • 使用这种方法,模型特定的属性 durationbody 可以设置为必填
  • type 判别列指示每条记录是表示视频还是文章项。

Prisma Client API

这次,你可以直接通过 PrismaClient 实例上的 videoarticle 属性查询视频和文章。

查询视频和文章

如果你想访问共享属性,你需要使用 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 生成的 VideoArticle 类型与 Activity 类型来定义 VideoArticle 类型。这些组合创建了一个具有所需属性的新类型。请注意,我们还省略了 type 判别列,因为它在特定类型上不再需要:

import {
Video as VideoDB,
Article as ArticleDB,
Activity,
} from '@prisma/client'

type Video = Omit<VideoDB & Activity, 'type'>
type Article = Omit<ArticleDB & Activity, 'type'>

定义这些类型后,你可以定义映射函数,将上述查询返回的类型转换为所需的 VideoArticle 类型。以下是 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 之间的权衡

  • 以下是 STI 与 MTI 两种主要方法的快速比较:
  • 数据模型:使用 MTI 时,数据模型可能感觉更整洁。使用 STI 时,可能会导致非常宽的行和许多包含 NULL 值的列。
  • 性能:MTI 可能会带来性能成本,因为你需要连接父表和子表才能访问模型的所有相关属性。
  • 类型:对于 Prisma ORM,MTI 已经为你提供了特定模型(即上述示例中的 ArticleVideo)的正确类型,而使用 STI 时你需要从头创建这些类型。

ID / 主键:使用 MTI 时,记录有两个 ID(一个在父表,一个在子表),它们可能不匹配。你需要在应用程序的业务逻辑中考虑这一点。

第三方解决方案