模块四:后端 API 与数据管理 | 第03讲:ORM 与 Drizzle——让 AI 写出可维护的数据访问层
本讲定位:用 Drizzle ORM 把数据库表变成 TypeScript 里的「一等公民」,让编译器替你挡住 80% 的低级错误。
项目锚点:VibeNote(Next.js 14 + PostgreSQL)。
阅读线索:承接第02讲 DDL;本节给出完整可粘贴的 schema、迁移命令与查询示例。
一、为什么要 ORM?不是为了少写 SQL 那么简单
在没有 ORM 的时代,手写 SQL 字符串拼接是 bug 重灾区:类型对不上、字段改名漏改、NULL 处理不一致。ORM(Object-Relational Mapping)的核心价值是:
- 结构即契约:表结构在代码里有单一真相来源(SSOT)。
- 类型安全:
select的结果 shape 与 TypeScript 对齐,重构可追踪。 - 迁移可重复:schema 变更可版本化,避免「只有我本机能跑」。
- AI 更稳:当你要求 AI「改 notes 表增加 summary 字段」,ORM 路径会让改动点集中在 schema + 少量查询文件。
ORM 不是银弹:复杂报表、批量 ETL、极致优化有时仍要写 SQL。Drizzle 的长处是 「SQL-like + 类型推断」,离原生 SQL 很近,心智负担低于某些「黑盒查询构建器」。
二、Drizzle 在生态里的位置:和 Prisma 怎么选?
| 维度 | Drizzle | Prisma |
|---|---|---|
| 风格 | 接近 SQL 的查询 API | 声明 schema + 客户端 API |
| 类型体验 | 强,尤其与 pg 搭配 | 强,生成型客户端 |
| 迁移工具 | drizzle-kit | prisma migrate |
| AI 生成稳定性 | 高(代码即查询,易 diff) | 高(需注意 generator 与版本) |
| 运行时体积 | 相对轻 | 相对重(客户端层) |
选型建议(VibeNote):你已经采用 PostgreSQL + Next.js,希望细粒度控制查询并与 Serverless 友好协作——Drizzle 非常合适。若团队极度依赖 Prisma Studio 与一体化工具链,Prisma 也正确;关键是选定后写进工程规范,不要来回横跳。
flowchart LR
subgraph Code["应用代码"]
S[schema.ts]
Q[queries/*]
R[Route Handlers]
end
subgraph Tool["工具链"]
K[drizzle-kit]
M[migrations/*]
end
subgraph DB["PostgreSQL"]
T[(tables)]
end
S --> K
K --> M
M --> T
Q --> S
R --> Q
Q -->|SQL via driver| T
三、安装与项目结构(可复制)
pnpm add drizzle-orm postgres
pnpm add -D drizzle-kit dotenv
推荐目录:
src/db/
index.ts # 导出 db 实例
schema.ts # 表定义
queries/
notes.ts # 笔记相关查询(可选拆分)
drizzle.config.ts
若你用
lib/别名而非src/,把路径替换即可,保持「db 与业务查询分层」这一原则不变。
3.1 drizzle.config.ts
// drizzle.config.ts
import "dotenv/config";
import { defineConfig } from "drizzle-kit";
export default defineConfig({
schema: "./src/db/schema.ts",
out: "./drizzle",
dialect: "postgresql",
dbCredentials: {
url: process.env.DATABASE_URL!,
},
});
package.json 脚本:
{
"scripts": {
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate",
"db:studio": "drizzle-kit studio"
}
}
四、把 VibeNote 表翻译成 Drizzle Schema(完整示例)
// src/db/schema.ts
import {
pgTable,
uuid,
text,
timestamp,
primaryKey,
uniqueIndex,
index,
} from "drizzle-orm/pg-core";
import { relations } from "drizzle-orm";
export const users = pgTable("users", {
id: uuid("id").primaryKey().defaultRandom(),
email: text("email").notNull().unique(),
name: text("name").notNull().default(""),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
});
export const notes = pgTable(
"notes",
{
id: uuid("id").primaryKey().defaultRandom(),
userId: uuid("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
title: text("title").notNull(),
contentMd: text("content_md").notNull().default(""),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true })
.notNull()
.defaultNow(),
},
(t) => ({
userUpdatedIdx: index("notes_user_updated_idx").on(t.userId, t.updatedAt),
titleIdx: index("notes_title_search_idx").on(t.userId, t.title),
})
);
export const tags = pgTable(
"tags",
{
id: uuid("id").primaryKey().defaultRandom(),
userId: uuid("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
name: text("name").notNull(),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
},
(t) => ({
userNameUk: uniqueIndex("tags_user_name_unique").on(t.userId, t.name),
})
);
export const noteTags = pgTable(
"note_tags",
{
noteId: uuid("note_id")
.notNull()
.references(() => notes.id, { onDelete: "cascade" }),
tagId: uuid("tag_id")
.notNull()
.references(() => tags.id, { onDelete: "cascade" }),
},
(t) => ({
pk: primaryKey({ columns: [t.noteId, t.tagId] }),
byTag: index("note_tags_tag_idx").on(t.tagId),
byNote: index("note_tags_note_idx").on(t.noteId),
})
);
export const usersRelations = relations(users, ({ many }) => ({
notes: many(notes),
tags: many(tags),
}));
export const notesRelations = relations(notes, ({ one, many }) => ({
user: one(users, { fields: [notes.userId], references: [users.id] }),
noteTags: many(noteTags),
}));
export const tagsRelations = relations(tags, ({ one, many }) => ({
user: one(users, { fields: [tags.userId], references: [users.id] }),
noteTags: many(noteTags),
}));
export const noteTagsRelations = relations(noteTags, ({ one }) => ({
note: one(notes, { fields: [noteTags.noteId], references: [notes.id] }),
tag: one(tags, { fields: [noteTags.tagId], references: [tags.id] }),
}));
执行生成迁移(在配置好 DATABASE_URL 的机器上):
pnpm db:generate
pnpm db:migrate
教学提示:第一次接入时,可用
drizzle-kit push做原型快速对齐;团队项目优先迁移文件可审查。
五、db 实例:postgres.js 驱动(Serverless 友好)
// src/db/index.ts
import { drizzle } from "drizzle-orm/postgres-js";
import postgres from "postgres";
import * as schema from "./schema";
const connectionString = process.env.DATABASE_URL!;
const client = postgres(connectionString, { prepare: false });
export const db = drizzle(client, { schema });
export type DB = typeof db;
六、CRUD 与多对多:可读、可测、可教给 AI
6.1 创建笔记 + 标签(事务)
// src/db/queries/notes.ts
import { and, desc, eq, ilike, sql } from "drizzle-orm";
import { db } from "../index";
import { noteTags, notes, tags } from "../schema";
export async function createNoteWithTags(input: {
userId: string;
title: string;
contentMd: string;
tagNames: string[];
}) {
return db.transaction(async (tx) => {
const [note] = await tx
.insert(notes)
.values({
userId: input.userId,
title: input.title,
contentMd: input.contentMd,
})
.returning();
const uniqueNames = Array.from(new Set(input.tagNames.map((n) => n.trim()).filter(Boolean)));
const tagIds: string[] = [];
for (const name of uniqueNames) {
const [tag] = await tx
.insert(tags)
.values({ userId: input.userId, name })
.onConflictDoUpdate({
target: [tags.userId, tags.name],
set: { name },
})
.returning();
tagIds.push(tag.id);
}
if (tagIds.length > 0) {
await tx.insert(noteTags).values(
tagIds.map((tagId) => ({ noteId: note.id, tagId }))
);
}
return note;
});
}
说明:
onConflictDoUpdate的目标列需与uniqueIndex("tags_user_name_unique")对齐;若你更偏好「先查后插」,也可,但要处理并发下的竞态。
6.2 列表 + 搜索(简单 ILIKE 版)
export async function listNotesForUser(input: {
userId: string;
q?: string;
limit?: number;
}) {
const limit = Math.min(input.limit ?? 20, 50);
const where = input.q
? and(eq(notes.userId, input.userId), ilike(notes.title, `%${input.q}%`))
: eq(notes.userId, input.userId);
return db
.select()
.from(notes)
.where(where)
.orderBy(desc(notes.updatedAt))
.limit(limit);
}
6.3 单条更新(动态字段)
export async function updateNote(input: {
userId: string;
noteId: string;
patch: { title?: string; contentMd?: string };
}) {
const [row] = await db
.update(notes)
.set({
...input.patch,
updatedAt: sql`now()`,
})
.where(and(eq(notes.id, input.noteId), eq(notes.userId, input.userId)))
.returning();
return row ?? null;
}
七、类型安全到底省下了什么?
flowchart TD
A[schema 变更] --> B[TypeScript 编译报错]
B --> C[Route Handler 修复]
C --> D[前端类型同步 optional]
D --> E[联调时间下降]
当你把查询收拢到 queries/*,AI 改 schema 后编译器会指向所有破坏点,这比「运行时才 500」便宜太多。
八、给 AI 的约束模板(强烈建议贴进 AGENTS.md)
Drizzle + postgres.js;所有表在
src/db/schema.ts;业务查询只允许出现在src/db/queries/**;Route Handler 不得手写 SQL 字符串;迁移必须可审查;returning()用于需要 id 的场景;多表写入必须db.transaction。
这几条规则能把「AI 乱飞」压回工程轨道。
九、思考题
onConflictDoUpdate与「先 select 再 insert」相比,各自适合什么并发级别?- 何时你会把 Drizzle relations 与
db.queryAPI 引入项目?代价是什么? - 如果要把
content_md拆到独立表(大字段隔离),迁移步骤如何设计才能零停机?
十、本节小结
- ORM 的价值是 契约 + 类型 + 迁移,不是省略思考。
- Drizzle 适合希望 SQL 可控 + TS 一体 的 Next.js 项目。
- VibeNote 的 schema、索引与关系已在代码层对齐第02讲模型。
- 查询分层 + 事务规范,是让 AI 可持续改代码的前提。
十一、下一讲预告
第04讲:认证与授权——用户系统怎么做才稳?中间件策略实战
我们将引入 Auth.js(NextAuth v5)思路:OAuth(GitHub/Google)、会话与 JWT 的边界、在 App Router 下保护 /api/* 与页面路由的 Middleware 策略,并把 notes.user_id 与登录用户 id 对齐,彻底关闭「匿名写库」的后门。