4.3 ORM 与 Drizzle——让 AI 写出可维护的数据访问层

2 阅读5分钟

模块四:后端 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)的核心价值是:

  1. 结构即契约:表结构在代码里有单一真相来源(SSOT)。
  2. 类型安全select 的结果 shape 与 TypeScript 对齐,重构可追踪。
  3. 迁移可重复:schema 变更可版本化,避免「只有我本机能跑」。
  4. AI 更稳:当你要求 AI「改 notes 表增加 summary 字段」,ORM 路径会让改动点集中在 schema + 少量查询文件。

ORM 不是银弹:复杂报表、批量 ETL、极致优化有时仍要写 SQL。Drizzle 的长处是 「SQL-like + 类型推断」,离原生 SQL 很近,心智负担低于某些「黑盒查询构建器」。


二、Drizzle 在生态里的位置:和 Prisma 怎么选?

维度DrizzlePrisma
风格接近 SQL 的查询 API声明 schema + 客户端 API
类型体验强,尤其与 pg 搭配强,生成型客户端
迁移工具drizzle-kitprisma 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 乱飞」压回工程轨道。


九、思考题

  1. onConflictDoUpdate 与「先 select 再 insert」相比,各自适合什么并发级别?
  2. 何时你会把 Drizzle relations 与 db.query API 引入项目?代价是什么?
  3. 如果要把 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 对齐,彻底关闭「匿名写库」的后门。