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 |
两个配套事实。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. 可带走的
- Client 挂
globalThis缓存,防热重载堆连接池;SIGINT 时 $disconnect。 - connect 挂已存在的关联;connectOrCreate 有就接没有先建;disconnect 只摘连接。
- include 拼装关联(select 限定字段),底层不是 JOIN;
_count只取数量。 - 计数用
{ increment: 1 }原子自增,别读-改-写。 - 嵌套 create 自动走事务;一对一更新用 upsert。
- 解构 req.body 白名单,别把请求体整个喂给数据库。
- 动态拼 where + skip/take 分页 + Promise.all 并行取总数;静态路由写在
/:id前。 - P2002→409、P2025→404、P2003→409、验证错→400。
$transaction([...])适合无决策批操作;$transaction(async tx => …)适合读-决策-写;要更强隔离加 Serializable 或 FOR UPDATE。- 测试双轨:mockDeep+vi.mock 求快,真库+独立测试库+fileParallelism:false 求信。
10. 原文地图
| 主题 | 原书章 | 原文位置 |
|---|---|---|
| globalThis 实例缓存 | Setting up Prisma Client with instance caching | text/68-fm-setting-up-prisma-client-with-instance-caching.txt:21(搜「run out of database connections」) |
| 优雅断连 | Updating the Express Server | text/69-fm-updating-the-express-server.txt:32(搜「process.on("SIGINT", async () => {」) |
| CRUD 与并发下自增 ID | Building the user routes with Prisma | text/70-fm-building-the-user-routes-with-prisma.txt:126(搜「race condition」) |
| include/connect | Building the POST routes | text/71-fm-building-the-post-routes.txt:49(搜「include」) · text/71-fm-building-the-post-routes.txt:125(搜「author:」) |
| 原子 increment | Building the POST routes | text/71-fm-building-the-post-routes.txt:173(搜「atomic increment」) |
| include 不是 JOIN | Building the POST routes | text/71-fm-building-the-post-routes.txt:227(搜「does not use a SQL JOIN」) |
| 输入白名单 | Adding relation-aware user routes | text/72-fm-adding-relation-aware-user-routes.txt:90(搜「input whitelisting」) |
| 嵌套 create 与 upsert | Adding relation-aware user routes | text/72-fm-adding-relation-aware-user-routes.txt:108(搜「nested create」) · text/72-fm-adding-relation-aware-user-routes.txt:149(搜「upsert」) |
| connectOrCreate / disconnect | Adding category support to posts | text/73-fm-adding-category-support-to-posts.txt:25(搜「connectOrCreate」) · text/73-fm-adding-category-support-to-posts.txt:45(搜「disconnect」) |
| 动态过滤与分页 | Advanced filtering | text/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 middleware | text/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 middleware | text/75-fm-building-an-error-handling-middleware.txt:241(搜「automatically caught」) |
| 卖票场景与事务动机 | Advanced filtering | text/74-fm-advanced-filtering.txt:223(搜「sold-out play」) |
| 顺序事务 | Sequential transactions | text/76-fm-sequential-transactions.txt:13(搜「rolled back」) |
| 交互事务与隔离级 | Interactive transactions | text/77-fm-interactive-transactions.txt:9(搜「transaction client」) · text/77-fm-interactive-transactions.txt:21(搜「READ COMMITTED」) |
| seed 与反序清空 | Database seeding | text/78-fm-database-seeding.txt:55(搜「A seed script starts by cleaning up exis」) |
| $queryRaw 参数化与 TypedSQL | Raw SQL and TypedSQL | text/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 strategy | text/80-fm-testing-strategy.txt:5(搜「complementary approaches to testing data」) |
| fileParallelism 与命名导出 | Setting up the test infrastructure | text/81-fm-setting-up-the-test-infrastructure.txt:35(搜「fileParallelism: false,」) |
| mockDeep + vi.mock | Unit tests with mocked Prisma | text/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 database | text/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
-
出处:「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」)。 ↩ -
出处:「Updating the Express Server」第 25–33 段(
text/69-fm-updating-the-express-server.txt:32,搜「process.on("SIGINT", async () => {」)。 ↩ -
出处:「Building the user routes with Prisma」第 57–89 段(
text/70-fm-building-the-user-routes-with-prisma.txt:126,搜「race condition」;:71,搜「atomically」)。 ↩ -
出处:「Building the POST routes」第 41–57 段(
text/71-fm-building-the-post-routes.txt:125,搜「author:」)。 ↩ -
出处:「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 -
出处:「Building the POST routes」第 75–87 段(
text/71-fm-building-the-post-routes.txt:173,搜「atomic increment」)。 ↩ -
出处:「Adding category support to posts」第 25–49 段(
text/73-fm-adding-category-support-to-posts.txt:25,搜「connectOrCreate」;:45,搜「removes the link」)。 ↩ ↩2 -
出处:「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 -
出处:「Advanced filtering」第 7–63 段(
text/74-fm-advanced-filtering.txt:23,搜「dynamically」;:39,搜「cursor-based」;:47,搜「Promise.all」;:59,搜「defined before」)。 ↩ ↩2 ↩3 -
出处:「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 -
出处:「Building an error-handling middleware」第 85–119 段(
text/75-fm-building-an-error-handling-middleware.txt:241,搜「automatically caught」;:111,搜「Zod」)。 ↩ ↩2 ↩3 -
出处:「Advanced filtering」第 75–109 段(
text/74-fm-advanced-filtering.txt:223,搜「sold-out play」;:99,搜「single indivisible operation」)。 ↩ -
出处:「Sequential transactions」第 1–31 段(
text/76-fm-sequential-transactions.txt:13,搜「rolled back」)。 ↩ -
出处:「Interactive transactions」第 1–15 段(
text/77-fm-interactive-transactions.txt:9,搜「transaction client」;:11,搜「READ COMMITTED」;:13,搜「Serializable」)。 ↩ ↩2 -
出处:「Database seeding」第 15–25 段(
text/78-fm-database-seeding.txt:55,搜「A seed script starts by cleaning up exis」)。 ↩ -
出处:「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 -
出处:「Testing strategy」第 3–15 段(
text/80-fm-testing-strategy.txt:5,搜「complementary approaches to testing data」;:15,搜「Both types are needed」)。 ↩ ↩2 -
出处:「Setting up the test infrastructure」第 11–39 段(
text/81-fm-setting-up-the-test-infrastructure.txt:35,搜「fileParallelism: false,」;:23,搜「separate test database」)。 ↩