— 3 min read
在上一篇文章中,我们从 NodeJS 社区的传统 ORM 讲起,介绍了它们的特征以及传统 ORM 的 Active Record、Data Mapper 模式,再到 Prisma 的环境配置、基本使用以及单表实践。在这篇文章中,我们将介绍 Prisma 的多表、多表级联、多数据库实战,以及 Prisma 与 GraphQL 的协作,在最后,我们还会简单的收尾来展开聊一聊 Prisma,帮助你建立大致的印象:Prisma 的优势在哪里?什么时候该用 Prisma?
在大部分情况下我们的数据库中不会只有一张数据表,多表下的操作(级联、事务等)也是判断一个 ORM 是否易用的重要指标。在这一方面 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 String4 posts Post[]5}6
7model Post {8 id Int @id @default(autoincrement())9 postUUID String @default(uuid())10 title String11 content String?12 published Boolean @default(false)13 createdAt DateTime @default(now())14 updatedAt DateTime @updatedAt15 author User @relation(fields: [authorId], references: [id])16 authorId Int17 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? @unique26}27
28model User {29 id Int @id @default(autoincrement())30 name String @unique31 age Int @default(0)32 posts Post[]33 profile Profile?34 avaliable Boolean @default(true)35 createdAt DateTime @default(now())36 updatedAt DateTime @updatedAt37}
在这里我们主要关注 Prisma 如何连接各个实体,明显能看到相关代码应该是:
1posts Post[]2profile Profile?
在关系的拥有者中(在一对一、一对多关系中,通常认为只存在一方拥有者,而在多对多关系中,通常认为互为拥有者)我们只需要定义字段以及字段代表的实体,而在关系的另一方中,我们需要使用 prisma 的@relation
语法来标注这一字段表征的关系,如
1user User? @relation(fields: [userId], references: [id])2userId Int? @unique
userId
和user
必须保持一致的可选/必选,即要么同为可选,要么同为必选。userId
对应的字段。@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 实体自身除了级联以外的字段。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 // update11 // upsert12 // delete13 // disconnect(true)14 // create15 // connect16 // connectOrCreate17 },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 // update16 // updateMany17 // delete18 // deleteMany19 // disconnect: [20 // {21 // id: 1,22 // },23 // ],24 // connect25 // create26 // connectOrCreate27 // upsert28 },29 },30 select: simpleSelectFields,31});
你可以使用 update/delete 来一把梭的进行所有级联关系的更新,如用户 VIP 等级提升,更新用户所有文章的曝光率;也可以使用 updateMany/deleteMany 对符合条件的级联实体做精细化的修改;亦或者,你可以直接使用 set 方法,覆盖所有的级联实体关系(如 set 为[],则用户的所有级联文章关系都将消失)。
至于多对多的更新操作,类似于上面一对多的批量更新,这里就不做展开了。
本部分的代码见multi-models-advanced。
这一部分不会做过多展开,毕竟本文还是属于入门系列的文章。但是我在 GitHub 代码仓库中提供了相关的示例,如果你有兴趣,直接查看即可。
在这里简单的概括下代码仓库中的例子:
本部分代码见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 String4
5 createdAt DateTime @default(now())6 updatedAt DateTime @updatedAt7}8
9model Value {10 vid Int @id @default(autoincrement())11 key String12 value String13
14 createdAt DateTime @default(now())15 updatedAt DateTime @updatedAt16}
以上的 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 没区别,是吧...
本部分的示例见with-typeorm 与 with-typegoose
既然 Prisma + Prisma 没问题,那么 Prisma + 其他 ORM 呢?其实同样很简单,以 TypeORM 的例子为例,步骤是相同的:
1async function main() {2 // Setup TypeORM Connection3 const connection = await createConnection({4 type: "sqlite",5 database: IS_PROD6 ? "./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}
本部分的代码见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.ts2export const server = new ApolloServer({3 schema,4 introspection: true,5 context: { prisma },6});7
8// Todo.resolver.ts9// 更多 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: IContext23 ): 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: IContext36 ): 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 的出现是为了解决什么?它和其他操作数据库的方案(SQL、ORM、Query Builder)比起来,有什么新的优势吗?
完整版内容参考方方老师翻译的这篇 Why Prisma?
.
的方式来访问嵌套的数据实体,但实际上 User、Post、Category 应该是独立的实体,它们是独立集合而不是对象属性的关系。在 ORM 中我们使用.
的方式来访问,但底层它也是通过外键 JOIN 的方式来构造 SQL 的。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-prisma
和 typegraphql-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,阿里内部都在用,很难不资瓷!