编程语言研究书库

Prisma 实战 — CRUD、关系查询、事务、错误处理与数据库测试

Prisma 实战 — CRUD、关系查询、事务、错误处理与数据库测试

这一章讲什么: 上一章建好的四张表,这一章接进 Express。 你会看到同一件事——发一篇文章——在 Prisma 里怎么一步步长出关联 (作者、分类)、长出计数(浏览量)、长出可靠性(事务、错误处理)。 读完你能写出一个带过滤、分页、关联查询的生产形状 API,并给它配两套测试。

1. 顶层全景:请求怎么穿过 Prisma 到达 PostgreSQL

  HTTP 请求 → Express 路由(async handler)
                │
                ▼
            prisma.post.create({ data: …, include: … })
                │  生成 SQL(想偷看就 DEBUG="prisma:query")
                ▼
            PostgreSQL → 结果映射成类型化对象 → res.json

  图说:路由只管 happy path;出错时 Express 5 自动把异常转交错误中间件。

第一个工程细节:Prisma Client 必须缓存。开发时 tsx watch 每次热重载 都重新执行模块,若每次 new PrismaClient() 就新建一个连接池,重载多了 连接池堆积,最终把数据库连接耗尽。解法是把实例挂在 globalThis 上 (进程级、不受模块重执行影响):有就用(?? 左侧),没有才建1。 Prisma 7 里连接串交给 PrismaPg 适配器,SIGINT(Ctrl+C)时 $disconnect() 优雅断连,不留孤儿连接告警2。

2. 主线例子:发一篇文章走全程

第 ① 步:create 一个用户。 prisma.user.create({ data }) 插入一行; findUnique({ where }) 按唯一字段查,查不到回 null(于是有了 404); updateMany/delete 同款。和内存版对比有两个进步:每个 handler 都是 async(数据库返回 Promise);自增 ID 由数据库序列原子分配—— 内存版 Math.max(...)+1 在并发下两个请求会算出同一个新 ID,数据库版 这个问题直接消失3。

第 ② 步:发帖并挂上作者(connect)。

prisma.post.create({
  data: {
    title, content,
    author: { connect: { id: authorId } },   // 按 ID 关联已存在的用户
  },
})

connect 的语义:「把这个新帖子接到一个已存在的用户上」。若那个用户 不存在,Prisma 抛 P2025(见第 4 节)4。

第 ③ 步:取帖子时带出作者(include/select)。

prisma.post.findMany({
  include: { author: { select: { id: true, username: true } } },
  orderBy: { createdAt: "desc" },
})

include 拉关联,select 限定只要哪些字段;_count: { select: { posts: true } } 能只取数量不取数据。书里点破了一个实现细节:include 底层不是 SQL JOIN——Prisma 分表查询、在应用层拼装(有预览特性 relationJoins 可切到数据库级 JOIN,本项目未用)5。

第 ④ 步:浏览量 +1(原子自增)。

data: { views: { increment: 1 } }

为什么不用「读出来、加一、写回去」?并发下两个请求会互相覆盖丢计数。 increment: 1 让数据库在列上原子加一,读-改-写竞赛消失6。

第 ⑤ 步:挂分类(connectOrCreate)与摘分类(disconnect)。

categories: {
  connectOrCreate: categoryNames.map(name => ({ where: { name }, create: { name } })),
}

多对多的日常:传来的分类名可能已存在、可能不存在——connectOrCreate 「有就接、没有先建再接」,一条命令搞定7。disconnect 摘掉的是连接, 两边记录都不删7。

第 ⑥ 步:嵌套 create 与 upsert(用户+档案)。 建用户时若带了 bio, profile: { create: { bio } } 让 Prisma 在一次操作里完成两条 INSERT, 自动包进事务;一对一的档案更新用 upsert——「有就改、没有就建」, 省掉先查再分支的样板8。

安全注脚(本章最重要的工程提醒): 路由里要解构出期望字段 (const { username, email, role, bio } = req.body),别把 req.body 整个塞进 data——客户端塞个 role: "ADMIN" 进来,数据库照单全收。 这是最简单的输入白名单;真实项目里角色分配应走单独的 admin 路由, 由认证/授权中间件把守8。

3. 动态过滤与分页:让 GET 变得有用

生产形状的列表接口:按关键字搜、按状态筛、按分类筛、分页—— 核心手法是根据客户端给了哪些 query 参数,动态拼 where9:

  • mode: "insensitive" 大小写不敏感的 contains;
  • 关系过滤三件套:some(至少一个匹配)/every/none;
  • 分页用 skip+take(offset 式),Promise.all([findMany, count]) 同时跑数据查询和总数查询,元信息(关于数据的数据)回 meta: { total, page, totalPages };
  • 大数据集应换 cursor 式分页(不用数、不用跳行);limit 要在 服务端封顶(如 100),防一次拉全表9。

还有一个路由顺序陷阱:静态路径(/api/v1/users/active-authors)必须 写在 /:id 之前,否则 "stats" 会被 :id 当参数吞掉——Express 按声明顺序匹配9。

4. 错误处理:把 Prisma 错误码翻译成 HTTP

数据库错误不该以 500 和一堆乱码面对用户。Prisma 抛的是带错误码的 具体类型,中间件逐个翻译10:

错误码含义HTTP
P2002唯一约束冲突(重复 email)409 Conflict
P2025记录不存在(改/删了没有的行,或 connect 到不存在的记录)404
P2003外键约束失败(如删了 Restrict 的父行)409
验证错误(PrismaClientValidationError)数据不符合 schema(如 id 是 "abc")400

10

两个配套事实。Express 5 里 async handler 抛错或返回 rejected Promise 会自动转给错误中间件,不需要处处 try/catch(书中为教学 仍显式写 try/catch)11。Number("abc") 得 NaN 会被 Prisma 在 到达数据库前拦下转成 400——但真实项目应在路由入口就做参数校验 (Zod、express-validator),换来「id must be a positive integer」 这种人话,而不是泛泛的 "Invalid data provided"11。

5. 事务:查座位、锁座位、扣款,必须是一个动作

书里的噩梦场景:同一座戏票卖给了两个人——因为「查可用 → 预订 → 扣款」 是三个独立步骤,两个请求都通过了可用性检查。事务把多个操作合成 一个不可分割的整体:要么全成,要么全滚,中间状态对外不可见12。 两种写法:

顺序事务(一批操作按序执行,成批回滚):$transaction([op1, op2, …]) 收一组操作, 按顺序执行、结果按同序返回数组;任何一个失败,整批回滚13。 适合「操作已知、无需中途决策」的场景(如:确认作者存在 + 发布其全部草稿)。

交互事务:$transaction(async (tx) => { … }) 给你一个 tx 客户端,用法和 prisma 完全一样,但读、根据读到的做决定、再写 全在同一个事务里(如:转移某作者全部帖子给另一人——先验证两人存在、 再数帖子、再搬)。任何一步抛错,全部回滚14。

一个诚实的边界:PostgreSQL 默认隔离级 READ COMMITTED 不阻止 别人在你读写之间改行;真要「查完到写完之间谁也不许碰」,得加 { isolationLevel: "Serializable" },或原生 SELECT … FOR UPDATE 行锁14。

6. 种子数据、原始 SQL 与双轨测试

Seeding:package.json 的 prisma.seed 字段指到脚本, migrate reset 或 db seed 时自动跑;脚本先按外键反序清空 (先删 post 再删 user),再用嵌套 create 一次性造出「带档案、带文章、 挂好分类」的用户15。配套 prisma studio 是可视化浏览器,看数据 长什么样。

原始 SQL 的两条安全路。$queryRaw 用标签模板字符串, ${} 插值(把值嵌进模板字符串)被自动参数化(PostgreSQL 收到 $1 占位符、值分离传送), SQL 注入从结构上不可能16。TypedSQL(Prisma 7 预览特性): 把 .sql 文件放进 prisma/sql/,prisma generate --sql 让 Prisma 对照 schema 分析这条 SQL,生成参数和返回都带类型的函数—— SQL 改了返回不存在的列,TypeScript 当场报错16。

测试双轨17:

轨做法换来什么
单元(mock)vitest-mock-extended 的 mockDeep<PrismaClient>() 造深 mock,vi.mock("../../db.js", …) 把路由里的 prisma 整个换成 mock快、隔离;验的是「路由对不同输入输出响应正确」
集成(真库)独立测试库(.env.test 另一连接串),beforeEach(cleanDb) 按外键序清空慢、贵;验的是「查询真成立、约束真 enforced、关系真 behave」

两个只有踩过才知道的设置:集成测试要 fileParallelism: false—— 并行跑的文件互相删对方刚造的数据;app.ts 里 app.listen 包在 NODE_ENV !== "test" 判断里,并改用命名导出,测试才能直接 import app 而不抢端口18。

7. 作者的判断与证据

  • 「输入必须白名单,别把 req.body 整个给 Prisma」是明确的安全判断8。
  • 「单元与集成双轨都不能省,各补各的洞」是测试策略判断17。
  • 「Prisma 官方基准未被独立复现」的怀疑口径(上一章)在本章延续为 「include 不是 JOIN」这类实现细节的如实交代5。
  • 「P2002→409」这类映射是社区约定而非标准,作者的选择与通行实践一致10。

8. 边界与局限

  • 并发控制的深度只开了一扇窗:隔离级别、行锁、死锁各提一句, 真到高并发扣库存,还需专门材料。
  • 校验靠「够用就行」:白名单解构 + Prisma 验证错误,撑住了演示, 但 Zod 才是真实项目的答案(书末自己也承认)11。
  • TypedSQL 是预览特性——API 可能变,采用前查当下官方状态(2026 年 中时点)。

判断(我们的,不是书里的): 本章一节的排布暗含一条很好的进阶路径: 每个机制都由「内存版怎么出毛病」引出——自增 ID 的竞态、读-改-写 的丢计数、两步操作的半成品状态。连续三次「旧做法翻车 → 新机制救场」, 比平铺 API 参考更能让人记住什么时候该用 increment、什么时候该上事务。 如果错,会错在: 「翻车引出」依赖读者真的写过内存版(第 9 章); 跳章而来的读者少了这层对照,这些机制会退化成「又一个 API 花样」。

9. 可带走的

  1. Client 挂 globalThis 缓存,防热重载堆连接池;SIGINT 时 $disconnect。
  2. connect 挂已存在的关联;connectOrCreate 有就接没有先建;disconnect 只摘连接。
  3. include 拼装关联(select 限定字段),底层不是 JOIN;_count 只取数量。
  4. 计数用 { increment: 1 } 原子自增,别读-改-写。
  5. 嵌套 create 自动走事务;一对一更新用 upsert。
  6. 解构 req.body 白名单,别把请求体整个喂给数据库。
  7. 动态拼 where + skip/take 分页 + Promise.all 并行取总数;静态路由写在 /:id 前。
  8. P2002→409、P2025→404、P2003→409、验证错→400。
  9. $transaction([...]) 适合无决策批操作;$transaction(async tx => …) 适合读-决策-写;要更强隔离加 Serializable 或 FOR UPDATE。
  10. 测试双轨:mockDeep+vi.mock 求快,真库+独立测试库+fileParallelism:false 求信。

10. 原文地图

主题原书章原文位置
globalThis 实例缓存Setting up Prisma Client with instance cachingtext/68-fm-setting-up-prisma-client-with-instance-caching.txt:21(搜「run out of database connections」)
优雅断连Updating the Express Servertext/69-fm-updating-the-express-server.txt:32(搜「process.on("SIGINT", async () => {」)
CRUD 与并发下自增 IDBuilding the user routes with Prismatext/70-fm-building-the-user-routes-with-prisma.txt:126(搜「race condition」)
include/connectBuilding the POST routestext/71-fm-building-the-post-routes.txt:49(搜「include」) · text/71-fm-building-the-post-routes.txt:125(搜「author:」)
原子 incrementBuilding the POST routestext/71-fm-building-the-post-routes.txt:173(搜「atomic increment」)
include 不是 JOINBuilding the POST routestext/71-fm-building-the-post-routes.txt:227(搜「does not use a SQL JOIN」)
输入白名单Adding relation-aware user routestext/72-fm-adding-relation-aware-user-routes.txt:90(搜「input whitelisting」)
嵌套 create 与 upsertAdding relation-aware user routestext/72-fm-adding-relation-aware-user-routes.txt:108(搜「nested create」) · text/72-fm-adding-relation-aware-user-routes.txt:149(搜「upsert」)
connectOrCreate / disconnectAdding category support to poststext/73-fm-adding-category-support-to-posts.txt:25(搜「connectOrCreate」) · text/73-fm-adding-category-support-to-posts.txt:45(搜「disconnect」)
动态过滤与分页Advanced filteringtext/74-fm-advanced-filtering.txt:23(搜「dynamically」) · text/74-fm-advanced-filtering.txt:127(搜「cursor-based」) · text/74-fm-advanced-filtering.txt:48(搜「const [posts, total] = await Promise.all」) · text/74-fm-advanced-filtering.txt:185(搜「defined before」)
错误码映射Building an error-handling middlewaretext/75-fm-building-an-error-handling-middleware.txt:32(搜「case "P2002": {」) · text/75-fm-building-an-error-handling-middleware.txt:39(搜「case "P2025": {」)
Express 5 自动转异常Building an error-handling middlewaretext/75-fm-building-an-error-handling-middleware.txt:241(搜「automatically caught」)
卖票场景与事务动机Advanced filteringtext/74-fm-advanced-filtering.txt:223(搜「sold-out play」)
顺序事务Sequential transactionstext/76-fm-sequential-transactions.txt:13(搜「rolled back」)
交互事务与隔离级Interactive transactionstext/77-fm-interactive-transactions.txt:9(搜「transaction client」) · text/77-fm-interactive-transactions.txt:21(搜「READ COMMITTED」)
seed 与反序清空Database seedingtext/78-fm-database-seeding.txt:55(搜「A seed script starts by cleaning up exis」)
$queryRaw 参数化与 TypedSQLRaw SQL and TypedSQLtext/79-fm-raw-sql-and-typedsql.txt:13(搜「The first approach uses tagged template 」) · text/79-fm-raw-sql-and-typedsql.txt:43(搜「TypedSQL」)
双轨测试策略Testing strategytext/80-fm-testing-strategy.txt:5(搜「complementary approaches to testing data」)
fileParallelism 与命名导出Setting up the test infrastructuretext/81-fm-setting-up-the-test-infrastructure.txt:35(搜「fileParallelism: false,」)
mockDeep + vi.mockUnit tests with mocked Prismatext/82-fm-unit-tests-with-mocked-prisma.txt:21(搜「mockDeep」) · text/82-fm-unit-tests-with-mocked-prisma.txt:36(搜「vi.mock("../../db.js", () => ({」)
集成测试与约束验证Integration tests with a real databasetext/83-fm-integration-tests-with-a-real-database.txt:23(搜「export async function cleanDb() {」) · text/83-fm-integration-tests-with-a-real-database.txt:74(搜「enforces unique email」)

Footnotes

  1. 出处:「Setting up Prisma Client with instance caching」第 3–15 段(text/68-fm-setting-up-prisma-client-with-instance-caching.txt:21,搜「run out of database connections」)。 ↩

  2. 出处:「Updating the Express Server」第 25–33 段(text/69-fm-updating-the-express-server.txt:32,搜「process.on("SIGINT", async () => {」)。 ↩

  3. 出处:「Building the user routes with Prisma」第 57–89 段(text/70-fm-building-the-user-routes-with-prisma.txt:126,搜「race condition」;:71,搜「atomically」)。 ↩

  4. 出处:「Building the POST routes」第 41–57 段(text/71-fm-building-the-post-routes.txt:125,搜「author:」)。 ↩

  5. 出处:「Building the POST routes」第 11–19 段与 89–93 段(text/71-fm-building-the-post-routes.txt:49,搜「include」;:91,搜「does not use a SQL JOIN」)。 ↩ ↩2

  6. 出处:「Building the POST routes」第 75–87 段(text/71-fm-building-the-post-routes.txt:173,搜「atomic increment」)。 ↩

  7. 出处:「Adding category support to posts」第 25–49 段(text/73-fm-adding-category-support-to-posts.txt:25,搜「connectOrCreate」;:45,搜「removes the link」)。 ↩ ↩2

  8. 出处:「Adding relation-aware user routes」第 33–57 段与 61–73 段(text/72-fm-adding-relation-aware-user-routes.txt:90,搜「input whitelisting」;:51,搜「nested create」;:65,搜「upsert」)。 ↩ ↩2 ↩3

  9. 出处:「Advanced filtering」第 7–63 段(text/74-fm-advanced-filtering.txt:23,搜「dynamically」;:39,搜「cursor-based」;:47,搜「Promise.all」;:59,搜「defined before」)。 ↩ ↩2 ↩3

  10. 出处:「Building an error-handling middleware」第 11–47 段(text/75-fm-building-an-error-handling-middleware.txt:32,搜「case "P2002": {」;:25,搜「P2025」;:37,搜「P2003」;:43,搜「400」)。 ↩ ↩2 ↩3

  11. 出处:「Building an error-handling middleware」第 85–119 段(text/75-fm-building-an-error-handling-middleware.txt:241,搜「automatically caught」;:111,搜「Zod」)。 ↩ ↩2 ↩3

  12. 出处:「Advanced filtering」第 75–109 段(text/74-fm-advanced-filtering.txt:223,搜「sold-out play」;:99,搜「single indivisible operation」)。 ↩

  13. 出处:「Sequential transactions」第 1–31 段(text/76-fm-sequential-transactions.txt:13,搜「rolled back」)。 ↩

  14. 出处:「Interactive transactions」第 1–15 段(text/77-fm-interactive-transactions.txt:9,搜「transaction client」;:11,搜「READ COMMITTED」;:13,搜「Serializable」)。 ↩ ↩2

  15. 出处:「Database seeding」第 15–25 段(text/78-fm-database-seeding.txt:55,搜「A seed script starts by cleaning up exis」)。 ↩

  16. 出处:「Raw SQL and TypedSQL」第 7–45 段(text/79-fm-raw-sql-and-typedsql.txt:13,搜「The first approach uses tagged template 」;:25,搜「typedSql」;:43,搜「catches the error」)。 ↩ ↩2

  17. 出处:「Testing strategy」第 3–15 段(text/80-fm-testing-strategy.txt:5,搜「complementary approaches to testing data」;:15,搜「Both types are needed」)。 ↩ ↩2

  18. 出处:「Setting up the test infrastructure」第 11–39 段(text/81-fm-setting-up-the-test-infrastructure.txt:35,搜「fileParallelism: false,」;:23,搜「separate test database」)。 ↩

On this page