Skip to content
Linbudu's Blog

【Prisma,下一代ORM,不仅仅是ORM(下篇)

3 min read

在上一篇文章中,我们从 NodeJS 社区的传统 ORM 讲起,介绍了它们的特征以及传统 ORM 的 Active Record、Data Mapper 模式,再到 Prisma 的环境配置、基本使用以及单表实践。在这篇文章中,我们将介绍 Prisma 的多表、多表级联、多数据库实战,以及 Prisma 与 GraphQL 的协作,在最后,我们还会简单的收尾来展开聊一聊 Prisma,帮助你建立大致的印象:Prisma 的优势在哪里?什么时候该用 Prisma?

Prisma 多表、多数据库实战

在大部分情况下我们的数据库中不会只有一张数据表,多表下的操作(级联、事务等)也是判断一个 ORM 是否易用的重要指标。在这一方面 Prisma 同样表现出色,类似于上篇文章中的单表示例,Prisma 同样提供了以简洁语法操作级联的能力。

Prisma 多表

本部分的示例代码见 multi-models

我们首先在 Prisma Schema 中定义多张数据表,各个实体之间的级联关系如下:

  • User -> Profile 1-1
  • User -> Post 1-m
  • Post -> Category m-n
1model Category {
2 id Int @id @default(autoincrement())
3 name String
4 posts Post[]
5}
6
7model Post {
8 id Int @id @default(autoincrement())
9 postUUID String @default(uuid())
10 title String
11 content String?
12 published Boolean @default(false)
13 createdAt DateTime @default(now())
14 updatedAt DateTime @updatedAt
15 author User @relation(fields: [authorId], references: [id])
16 authorId Int
17 categories Category[]
18}
19
20model Profile {
21 id Int @id @default(autoincrement())
22 bio String?
23 profileViews Int @default(0)
24 user User? @relation(fields: [userId], references: [id])
25 userId Int? @unique
26}
27
28model User {
29 id Int @id @default(autoincrement())
30 name String @unique
31 age Int @default(0)
32 posts Post[]
33 profile Profile?
34 avaliable Boolean @default(true)
35 createdAt DateTime @default(now())
36 updatedAt DateTime @updatedAt
37}

在这里我们主要关注 Prisma 如何连接各个实体,明显能看到相关代码应该是:

1posts Post[]
2profile Profile?

在关系的拥有者中(在一对一、一对多关系中,通常认为只存在一方拥有者,而在多对多关系中,通常认为互为拥有者)我们只需要定义字段以及字段代表的实体,而在关系的另一方中,我们需要使用 prisma 的@relation语法来标注这一字段表征的关系,如

1user User? @relation(fields: [userId], references: [id])
2userId Int? @unique
  • fields 属性位于当前的表中,userIduser必须保持一致的可选/必选,即要么同为可选,要么同为必选。
  • references 属性位于关系的另一方中,表征与userId对应的字段。
  • 除了 fields 与 reference 属性外,还可使用 name 属性来显式指定关系名称,这在一些情景下可以避免歧义。
  • 在一对一、一对多关系中,@relation是必须被使用的。

在多对多关系中,如 Post 与 Category,可以不使用@relation来声明级联关系,这样将会自动使用双方表中的@id来建立级联关系。如果你觉得这种隐式指定可能会带来歧义或者你需要额外定制,也可以使用额外的一张数据表,使用@relation分别与 Post、Category 建立一对多关系。

创建完毕 schema 后,执行yarn generate:multi来生成 Prisma Client,便可以开始使用了。

上面的级联关系如果以对象的形式表示,大概是这样的:

1const user = {
2 profile: {
3
4 }
5 posts: {
6 categories: {
7
8 }
9 }
10}

因此,在 Prisma 中我们也以类似的方式操作各张数据表:

1const simpleIncludeFields = {
2 profile: true,
3 posts: {
4 include: {
5 categories: true,
6 },
7 },
8};
9
10const createUserWithFullRelations = await prisma.user.create({
11 data: {
12 name: randomName(),
13 age: 21,
14 profile: {
15 create: {
16 bio: randomBio(),
17 },
18 },
19 posts: {
20 create: {
21 title: randomTitle(),
22 content: "鸽置",
23 categories: {
24 create: [{ name: "NodeJS" }, { name: "GraphQL" }],
25 },
26 },
27 },
28 },
29 include: simpleIncludeFields,
30});

来看看和单表操作中不同的部分:

  • 默认情况下,返回结果中不会包含级联关系,只会包含实体自身的标量,如'prisma.user.xxx'返回的结果只会包含 User 实体自身除了级联以外的字段。
  • 如果想要像上面的例子一样,操作实体的同时操作其多个级联关系,prisma 提供了 connect、create、connectOrCreate 方法,分别用于连接到已有的级联实体、创建新的级联实体以及动态判断。

connectOrCreate 的使用方式如下:

1const connectOrCreateRelationsUser = await prisma.user.create({
2 data: {
3 name: randomName(),
4 profile: {
5 connectOrCreate: {
6 where: {
7 id: 9999,
8 },
9 create: {
10 bio: "Created by connectOrCreate",
11 },
12 },
13 },
14 posts: {
15 connectOrCreate: {
16 where: {
17 id: 9999,
18 },
19 create: {
20 title: "Created by connectOrCreate",
21 },
22 },
23 },
24 },
25 select: simpleSelectFields,
26});

我们为 profile 和 post 使用了不存在的 ID 进行查找,因此 Prisma 会为我们自动创建级联实体。

看完了级联创建,再来看看级联的更新操作,一对一:

1const oneToOneUpdate = await prisma.user.update({
2 where: {
3 name: connectOrCreateRelationsUser.name,
4 },
5 data: {
6 profile: {
7 update: {
8 bio: "Updated Bio",
9 },
10 // update
11 // upsert
12 // delete
13 // disconnect(true)
14 // create
15 // connect
16 // connectOrCreate
17 },
18 },
19 select: simpleSelectFields,
20});

对于更新,prisma 直接提供了一系列便捷方法,覆盖绝大部分的 case(我暂时没发现有覆盖不到的),create、connect、connectOrCreate 同样存在于 user.update 方法上,还新增了 disconnect 来断开级联关系。

一对多的更新则多了一些不同:

1const oneToMnayUpdate = await prisma.user.update({
2 where: {
3 name: connectOrCreateRelationsUser.name,
4 },
5 data: {
6 posts: {
7 updateMany: {
8 data: {
9 title: "Updated Post Title",
10 },
11 where: {},
12 },
13 // set 与 many, 以及各选项类型
14 // set: [],
15 // update
16 // updateMany
17 // delete
18 // deleteMany
19 // disconnect: [
20 // {
21 // id: 1,
22 // },
23 // ],
24 // connect
25 // create
26 // connectOrCreate
27 // upsert
28 },
29 },
30 select: simpleSelectFields,
31});

你可以使用 update/delete 来一把梭的进行所有级联关系的更新,如用户 VIP 等级提升,更新用户所有文章的曝光率;也可以使用 updateMany/deleteMany 对符合条件的级联实体做精细化的修改;亦或者,你可以直接使用 set 方法,覆盖所有的级联实体关系(如 set 为[],则用户的所有级联文章关系都将消失)。

至于多对多的更新操作,类似于上面一对多的批量更新,这里就不做展开了。

多表级联进阶

本部分的代码见multi-models-advanced

这一部分不会做过多展开,毕竟本文还是属于入门系列的文章。但是我在 GitHub 代码仓库中提供了相关的示例,如果你有兴趣,直接查看即可。

在这里简单的概括下代码仓库中的例子:

  • 自关联,我们前面的级联关系都来自于不同实体之间,实际上相同实体之间的关联也是常见的。如用户邀请其他用户时,通常会有已邀请的用户和当前用户的邀请人这两个同样属于 User 实体的操作。
  • 使用中间表来构建多对多的关系,就如我们上面提到的 Post 与 Category 关系,其实在 Prisma 中还可以定义额外的一张 CategoriesOnPosts 表,显式的配置级联信息。这种情况下又要如何进行 CRUD 呢?
  • 细粒度的操作符,实体级别的 every/some/none,标量级别的 contain/startsWith/endsWith/equals/...,另外,Prisma 还支持JSON Filters来直接对 JSON 数据进行过滤(例子中没有体现)。

这我得来点骚操作啊

多个 Prisma Client

本部分代码见multi-clients

由于 Prisma 的独特用法,你应该很容易想到要创建多个 Prisma Client 来连接不同的数据库是非常容易地,并且和单个 Prisma Client 使用没有明显的差异。其他 ORM 的话则需要稍微折腾一些,如 TypeORM 需要创建多个连接池(TypeORM 中的每个连接并不是“单个的”,而是连接池的形式)。

在这个例子中,我们使用 Key-Value 的形式,一个 client 存储 key,另一个存储 value:

1model Key {
2 kid Int @id @default(autoincrement())
3 key String
4
5 createdAt DateTime @default(now())
6 updatedAt DateTime @updatedAt
7}
8
9model Value {
10 vid Int @id @default(autoincrement())
11 key String
12 value String
13
14 createdAt DateTime @default(now())
15 updatedAt DateTime @updatedAt
16}

以上的 model 定义是用两个 schema 文件储存的

在 npm scripts 中,我已经准备好了相关的 script,运行yarn generate:multi-db即可。

实际使用也和单个 Client 没差别:

1import { PrismaClient as PrismaKeyClient, Key } from "./prisma-key/client";
2import { PrismaClient as PrismaValueClient } from "./prisma-value/client";
3
4const keyClient = new PrismaKeyClient();
5const valueClient = new PrismaValueClient();

首先创建 key(基于 uuid),然后基于 key 创建 value:

1const key1 = await keyClient.key.create({
2 data: {
3 key: uuidv4(),
4 },
5 select: {
6 key: true,
7 },
8});
9
10const value1 = await valueClient.value.create({
11 data: {
12 key: key1.key,
13 value: "林不渡",
14 },
15 select: {
16 key: true,
17 value: true,
18 },
19});

真的就和单个 client 没区别,是吧...

Prisma 与其他 ORM 协作

本部分的示例见with-typeormwith-typegoose

既然 Prisma + Prisma 没问题,那么 Prisma + 其他 ORM 呢?其实同样很简单,以 TypeORM 的例子为例,步骤是相同的:

  • 创建 Prisma 连接
  • 创建 TypeORM 连接
  • 以 Prisma 创建 key
  • 使用 Prisma 的 key 创建 TypeORM 的 value
  • 查询所有 Prisma 的 key,用于查询所有 TypeORM 的 value
1async function main() {
2 // Setup TypeORM Connection
3 const connection = await createConnection({
4 type: "sqlite",
5 database: IS_PROD
6 ? "./dist/src/with-typeorm/typeorm-value.sqlite"
7 : "./src/with-typeorm/typeorm-value.sqlite",
8 entities: [ValueEntity],
9 synchronize: true,
10 dropSchema: true,
11 });
12
13 await ValueEntity.clear();
14 await prisma.prismaKey.deleteMany();
15
16 const key1 = await prisma.prismaKey.create({
17 data: {
18 key: uuidv4(),
19 },
20 select: {
21 key: true,
22 },
23 });
24
25 const insertValues = await ValueEntity.createQueryBuilder()
26 .insert()
27 .into(ValueEntity)
28 .values([
29 {
30 key: key1.key,
31 value: "林不渡",
32 },
33 ])
34 .execute();
35
36 const keys = await prisma.prismaKey.findMany();
37
38 for (const keyItem of keys) {
39 const key = keyItem.key;
40
41 console.log(`Search By: ${key}`);
42
43 const value = await ValueEntity.createQueryBuilder("value")
44 .where("value.key = :key")
45 .setParameters({
46 key,
47 })
48 .getOne();
49
50 console.log("Search Result: ", value);
51 console.log("===");
52 }
53
54 await prisma.$disconnect();
55 await connection.close();
56}

Prisma + GraphQL

本部分的代码见typegraphql-apollo-server

Prisma 和 GraphQL 有个共同点,那就是它们都是 SDL First 的,Prisma Schema 和 GraphQL Schema 在部分细节上甚至是一致的,如标量以及@语法(虽然 prisma 中是内置函数,GraphQL 中则是指令)等。而且,Prisma 内置了 DataLoader 来解决 GraphQL N+1 问题,所以你真的不想试试 Prisma + GraphQL 吗?

关于 DataLoader,可以参见我之前写的GraphQL N+1 问题到 DataLoader 源码解析,其中就包含了 Prisma2 中内置的 DataLoader 源码解析。

技术栈与要点:

  • 基于 TypeGraphQL 构建 GraphQL Schema 与 Resolver,这也是目前主流的一种方式,毕竟写原生 GraphQL 的话其实不太好扩展(除非借助 GraphQL-Modules),类似的方式还有使用 Nexus 来构建 Schema。

  • 基于 ApolloServer 构建 GraphQL 服务,它是目前使用最广的 GraphQL 服务端框架之一。

    我们将实例化完毕的 Prisma Client 挂载在 Context 中,这样在 Resolver 中就能够获取到 prisma 实例。

    这一方式其实就类似于 REST API 中,我们拆分应用程序架构为 Controller-Service 的结构,Controller 对应的即是这里的 Resolver,直接接受请求并处理。在 GraphQL 应用中你同样可以拆分一层 Service,但这里为了保持代码精简就没有采用。

    关于 Context API,建议阅读 Apollo 的官方文档

  • 在这里为了示范 GraphQL Generation 系列的技术栈(其实就是为了好玩),我还引入了 GraphQL-Code-Generator(基于构建完毕的 GraphQL Schema 生成 TS 类型定义)以及 GenQL(基于 Schema 生成 client,然后就可以以类似 Prisma Client 的方式调用各种方法了,还支持链式调用,很难不资瓷)

在这里我们直接看重要代码即可:

1// server.ts
2export const server = new ApolloServer({
3 schema,
4 introspection: true,
5 context: { prisma },
6});
7
8// Todo.resolver.ts
9// 更多 Query/Mutation 参考代码仓库
10@Resolver(TodoItem)
11export default class TodoResolver {
12 constructor() {}
13
14 @Query((returns) => [TodoItem!]!)
15 async QueryAllTodos(@Ctx() ctx: IContext): Promise<TodoItem[]> {
16 return await ctx.prisma.todo.findMany({ include: { creator: true } });
17 }
18
19 @Query((returns) => TodoItem, { nullable: true })
20 async QueryTodoById(
21 @Arg("id", (type) => Int) id: number,
22 @Ctx() ctx: IContext
23 ): Promise<TodoItem | null> {
24 return await ctx.prisma.todo.findUnique({
25 where: {
26 id,
27 },
28 include: { creator: true },
29 });
30 }
31
32 @Mutation((returns) => TodoItem, { nullable: true })
33 async UpdateTodo(
34 @Arg("updateParams", (type) => UpdateTodoInput) params: UpdateTodoInput,
35 @Ctx() ctx: IContext
36 ): Promise<TodoItem | null> {
37 try {
38 const origin = await ctx.prisma.todo.findUnique({
39 where: { id: params.id },
40 });
41
42 if (!origin) {
43 throw new Error();
44 }
45
46 return await ctx.prisma.todo.update({
47 where: {
48 id: params.id,
49 },
50 data: {
51 title: params.title ?? origin?.title!,
52 content: params?.content ?? origin?.content!,
53 type: params?.type ?? origin?.type!,
54 },
55 include: { creator: true },
56 });
57 } catch (error) {
58 return null;
59 }
60 }
61}

很明显和上面例子中的唯一差异就是这里推荐把 prisma 挂载到 context 上然后再调用方法,而不是为每个 Resolver 导入一次 Prisma Client。而在 Midway 与 Nest 这一类基于 IoC 机制的 Node 框架中,推荐的使用方法是将 Prisma Client 注册到容器中,然后注入到 Service 层中。

关于 Nest 与 Prisma 的使用,可参考仓库 README 中的介绍。

尾声:Prisma 展望

在本文的最后,让我们来扩展性的聊一聊 Prisma 吧:

  • Prisma 的出现是为了解决什么?它和其他操作数据库的方案(SQL、ORM、Query Builder)比起来,有什么新的优势吗?

    完整版内容参考方方老师翻译的这篇 Why Prisma?

    • 手写原生 SQL:只要你的能力到位,SQL 的控制力是最强的,其控制粒度也是最精细的,几乎没有对手。但前提是,能力到位。手写 SQL 会花费大量的时间,当你花了多一倍的时间手写 SQL 却没有得到正比的回报,我想你需要停下来思考下?
    • Query Builder:在上一篇文章我们讲到,Query Builder 不是 ORM,但它更加贴近原生的 SQL,Query Builder 的每一次链式调用都会对最终生成的 SQL 进行一次修改。另外,Query Builder 同样需要一定的 SQL 知识,如 leftJoin 等。
    • ORM,在 JavaScript 中通常使用 Class 的方式来定义数据表模型,看起来很贴心,但实际上会导致对象关系阻抗不匹配(Object-relational impedance mismatch)。 举例来说,我们习惯于通过 User.post.category 这种.的方式来访问嵌套的数据实体,但实际上 User、Post、Category 应该是独立的实体,它们是独立集合而不是对象属性的关系。在 ORM 中我们使用.的方式来访问,但底层它也是通过外键 JOIN 的方式来构造 SQL 的。
    • 那么 Prisma 呢?它不再需要你一边用 Class 定义一边告诉自己牢记这是关系型数据库了...,现在你可以直接用 JS 对象的模式来思考。它的控制力不如 SQL 与 Query Builder,因为你还是直接通过已经封装完毕的 create/update 方法来调用。然而,由于它的心智模型,你使用它就像是使用一个普通对象一样,比起 ORM 来易用的多。
  • Prisma 是 ORM 吗?

    当然是啦!

    只不过和传统的 ORM 不一样,Prisma 使用的方式是“声明式”的,在你的 prisma 文件中声明数据库结构即可,这一实现使得它可以是跨语言的(只需要配置 client.provider 即可,目前仅有prisma-client-js实现)。而传统 ORM 提供的使用方式则是“面向对象的”,你需要将你的数据表结构一一映射到与语言对应的模型类中去。

  • Prisma 和 GraphQL Generation

    上面说到,由于 Prisma 和 GraphQL 都是 Schema First,因此二者往往能够产生奇妙的化学反应。最容易想到的就是二者 Schema 的互相转化,但目前社区似乎没有类似的方案,原因无他,如果从 Prisma Schema 生成原生 GraphQL Schema,并没有太多意义,因为现在其实很少会书写原生的 Schema 了。其次,如果要从 Prisma 得到 GraphQL 的类型定义,也没有必要直接转换到原生,完全可以转换到高阶表示,如 Nexus 与 TypeGraphQL,所以目前社区有的也是 nexus-plugin-prismatypegraphql-prisma 这两个方案,前者生成 Nexus Type Builders,后者生成 TypeGraphQL Class 以及 CRUD Resolvers。

一体化框架

目前除了 React 这样的前端框架,Express 这样的后端框架,其实还有一体化框架(Monolithic Framework),这一类框架最早来自于 Ruby On Rails 的思路。

目前在前端领域中,一体化框架的思路主要是这样的:

  • 开发时,前端直接导入后端的函数,后端函数中直接进行数据源的操作(ORM 或者已有的 API),而不是前端后端各起一个服务。

  • 构建时,框架会自动把前端对后端的函数导入,转换为 HTTP 请求,而后端的函数则会呗构建为 FaaS 函数或者 API 服务。

  • BlitzJS,基于 NextJS + Prisma + GraphQL,但实际上不需要有 GraphQL 相关的知识,作者成功把 GraphQL 的 Query/Mutation 通过 Prisma 转成了普通的方法调用,你也不需要自己书写 Schema 了。

  • RedwoodJS,类似于 Blitz,基于 React + Prisma + GraphQL,但场景在 JAMStack,Blitz 更倾向于应用程序开发。并且和 Blitz 抹消了 GraphQL 的存在感不同,RedWoodJS 采用Cells来实现前后端通信。

  • Midway-Hooks,前端框架不绑定,支持 React/Vue,因此对应的支持 React Hooks 与 Composition API。开发服务器基于 Vite,可部署为单独 Server 或者 FaaS,阿里内部都在用,很难不资瓷!