用 Claude Code 实现 Drizzle ORM:PostgreSQL、迁移、Zod 与 CI
从 schema、migration、seed、transaction、Zod 到 CI,完整讲解如何用 Claude Code 实现 Drizzle ORM。
Drizzle ORM 是一个 TypeScript 优先的 ORM。它不像某些 ORM 那样把 SQL 完全藏起来,而是让你用接近 SQL 的方式写查询,同时从 schema 推导出类型。对 Claude Code 来说,这种结构很适合:它可以读表结构、推断关系、生成 migration,并解释某个查询为什么需要某个 index。
但数据库层不能只看“能编译”。一个 schema 可能看起来没问题,生成的 SQL 却会在生产环境删除列;一个 seed 脚本可能第一次能跑,第二次就撞上 unique 约束;一个 transaction 可能在等待邮件或 webhook 时长期占用数据库连接。本文用一个博客 API 作为例子,讲清楚如何让 Claude Code 负责 Drizzle ORM 的实现,同时保留人类必须 review 的地方。
建议同时查看官方资料:Drizzle ORM docs、Drizzle Kit docs、Transactions、Drizzle Zod docs,以及 Claude Code 官方文档。内部延伸阅读可以看 Prisma ORM 对比、数据库迁移指南、Zod 校验指南。
工作流
不要只让 Claude Code “安装 Drizzle”。更好的要求是:schema、migration、query、transaction、seed、validation、CI 一起交付。
flowchart LR
A["需求提示词"] --> B["schema.ts"]
B --> C["drizzle-kit generate"]
C --> D["review SQL migration"]
D --> E["query, transaction, seed"]
E --> F["Zod validation"]
F --> G["CI checks"]
可以这样提需求:
请用 Drizzle ORM 实现博客 API 的数据库层。
技术栈:
- PostgreSQL
- drizzle-orm, drizzle-kit, node-postgres
- 表: User, Post, Category, Comment, AuditLog
- Post 状态: draft, published, archived
- email 和 slug 必须唯一
- 文章列表支持状态、发布时间、作者、分类和搜索
- 删除 Post 时只 cascade Comment 和中间表
- 删除 User 时不能删除 Post
请输出 schema、migration 配置、query、transaction、seed、Zod 校验和 CI 配置。
schema 与 migration
下面的 schema 保留了真实项目中最容易出问题的部分:唯一约束、外键删除规则、列表查询 index、审计日志。
// db/schema.ts
import { relations } from "drizzle-orm";
import {
boolean,
index,
integer,
jsonb,
pgEnum,
pgTable,
primaryKey,
text,
timestamp,
uniqueIndex,
uuid,
varchar,
} from "drizzle-orm/pg-core";
export const postStatus = pgEnum("post_status", ["draft", "published", "archived"]);
export const users = pgTable(
"users",
{
id: uuid("id").defaultRandom().primaryKey(),
email: varchar("email", { length: 255 }).notNull(),
name: varchar("name", { length: 120 }).notNull(),
role: varchar("role", { length: 40 }).default("editor").notNull(),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
},
(table) => [
uniqueIndex("users_email_unique").on(table.email),
index("users_role_idx").on(table.role),
],
);
export const categories = pgTable(
"categories",
{
id: uuid("id").defaultRandom().primaryKey(),
slug: varchar("slug", { length: 120 }).notNull(),
name: varchar("name", { length: 120 }).notNull(),
},
(table) => [uniqueIndex("categories_slug_unique").on(table.slug)],
);
export const posts = pgTable(
"posts",
{
id: uuid("id").defaultRandom().primaryKey(),
slug: varchar("slug", { length: 160 }).notNull(),
title: varchar("title", { length: 160 }).notNull(),
body: text("body").notNull(),
status: postStatus("status").default("draft").notNull(),
authorId: uuid("author_id").notNull().references(() => users.id, { onDelete: "restrict" }),
viewCount: integer("view_count").default(0).notNull(),
featured: boolean("featured").default(false).notNull(),
publishedAt: timestamp("published_at", { withTimezone: true }),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
},
(table) => [
uniqueIndex("posts_slug_unique").on(table.slug),
index("posts_status_published_at_idx").on(table.status, table.publishedAt),
index("posts_author_id_idx").on(table.authorId),
],
);
export const postCategories = pgTable(
"post_categories",
{
postId: uuid("post_id").notNull().references(() => posts.id, { onDelete: "cascade" }),
categoryId: uuid("category_id").notNull().references(() => categories.id, { onDelete: "cascade" }),
},
(table) => [primaryKey({ columns: [table.postId, table.categoryId] })],
);
export const comments = pgTable(
"comments",
{
id: uuid("id").defaultRandom().primaryKey(),
postId: uuid("post_id").notNull().references(() => posts.id, { onDelete: "cascade" }),
authorId: uuid("author_id").notNull().references(() => users.id, { onDelete: "restrict" }),
body: text("body").notNull(),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
},
(table) => [
index("comments_post_created_at_idx").on(table.postId, table.createdAt),
index("comments_author_id_idx").on(table.authorId),
],
);
export const auditLogs = pgTable(
"audit_logs",
{
id: uuid("id").defaultRandom().primaryKey(),
action: varchar("action", { length: 80 }).notNull(),
targetId: uuid("target_id").notNull(),
metadata: jsonb("metadata").$type<Record<string, unknown>>(),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
},
(table) => [index("audit_logs_action_created_at_idx").on(table.action, table.createdAt)],
);
export const usersRelations = relations(users, ({ many }) => ({ posts: many(posts), comments: many(comments) }));
export const postsRelations = relations(posts, ({ one, many }) => ({
author: one(users, { fields: [posts.authorId], references: [users.id] }),
comments: many(comments),
categories: many(postCategories),
}));
export const categoriesRelations = relations(categories, ({ many }) => ({ posts: many(postCategories) }));
export const postCategoriesRelations = relations(postCategories, ({ one }) => ({
post: one(posts, { fields: [postCategories.postId], references: [posts.id] }),
category: one(categories, { fields: [postCategories.categoryId], references: [categories.id] }),
}));
export const commentsRelations = relations(comments, ({ one }) => ({
post: one(posts, { fields: [comments.postId], references: [posts.id] }),
author: one(users, { fields: [comments.authorId], references: [users.id] }),
}));
// drizzle.config.ts
import "dotenv/config";
import { defineConfig } from "drizzle-kit";
export default defineConfig({
schema: "./db/schema.ts",
out: "./drizzle",
dialect: "postgresql",
dbCredentials: { url: process.env.DATABASE_URL! },
verbose: true,
strict: true,
});
npm run db:generate
npm run db:check
npm run db:migrate
review migration 时重点看:是否有意外的 DROP,是否给已有数据表添加危险的 NOT NULL,ON DELETE 是否太宽,unique 和 index 是否覆盖真实查询。CI 通过不能替代 SQL review。
query、transaction 与 seed
// db/client.ts
import "dotenv/config";
import { drizzle } from "drizzle-orm/node-postgres";
import { Pool } from "pg";
import * as schema from "./schema";
export const pool = new Pool({ connectionString: process.env.DATABASE_URL });
export const db = drizzle(pool, { schema });
// db/posts.ts
import { and, desc, eq, ilike, sql } from "drizzle-orm";
import { z } from "zod";
import { db } from "./client";
import { auditLogs, categories, comments, postCategories, posts, users } from "./schema";
import { createPostInputSchema } from "./validation";
type CreatePostInput = z.infer<typeof createPostInputSchema>;
export async function createPost(input: CreatePostInput) {
const data = createPostInputSchema.parse(input);
return db.transaction(async (tx) => {
const [post] = await tx.insert(posts).values({
slug: data.slug,
title: data.title,
body: data.body,
authorId: data.authorId,
}).returning();
for (const slug of data.categorySlugs) {
const [category] = await tx.insert(categories)
.values({ slug, name: slug })
.onConflictDoUpdate({ target: categories.slug, set: { name: slug } })
.returning();
await tx.insert(postCategories)
.values({ postId: post.id, categoryId: category.id })
.onConflictDoNothing();
}
await tx.insert(auditLogs).values({
action: "post.create",
targetId: post.id,
metadata: { slug: post.slug },
});
return post;
});
}
export async function listPublishedPosts(params: { page?: number; perPage?: number; search?: string } = {}) {
const page = Math.max(params.page ?? 1, 1);
const perPage = Math.min(Math.max(params.perPage ?? 20, 1), 50);
const where = params.search
? and(eq(posts.status, "published"), ilike(posts.title, `%${params.search}%`))
: eq(posts.status, "published");
const [items, [{ total }]] = await Promise.all([
db.select({
id: posts.id,
slug: posts.slug,
title: posts.title,
publishedAt: posts.publishedAt,
authorName: users.name,
commentCount: sql<number>`count(${comments.id})::int`,
})
.from(posts)
.innerJoin(users, eq(posts.authorId, users.id))
.leftJoin(comments, eq(comments.postId, posts.id))
.where(where)
.groupBy(posts.id, posts.slug, posts.title, posts.publishedAt, users.name)
.orderBy(desc(posts.publishedAt), desc(posts.createdAt))
.limit(perPage)
.offset((page - 1) * perPage),
db.select({ total: sql<number>`count(*)::int` }).from(posts).where(where),
]);
return { items, pagination: { page, perPage, total, totalPages: Math.ceil(total / perPage) } };
}
seed 要写成可以重复执行的形式:
// db/seed.ts
import { db, pool } from "./client";
import { categories, postCategories, posts, users } from "./schema";
async function main() {
const [user] = await db.insert(users)
.values({ email: "masa@example.com", name: "Masa", role: "admin" })
.onConflictDoUpdate({ target: users.email, set: { name: "Masa", role: "admin", updatedAt: new Date() } })
.returning();
const [category] = await db.insert(categories)
.values({ slug: "drizzle", name: "Drizzle ORM" })
.onConflictDoUpdate({ target: categories.slug, set: { name: "Drizzle ORM" } })
.returning();
const [post] = await db.insert(posts)
.values({
slug: "claude-code-drizzle-demo",
title: "Claude Code Drizzle demo",
body: "A seeded post for local verification.",
status: "published",
authorId: user.id,
publishedAt: new Date(),
})
.onConflictDoUpdate({ target: posts.slug, set: { title: "Claude Code Drizzle demo", updatedAt: new Date() } })
.returning();
await db.insert(postCategories).values({ postId: post.id, categoryId: category.id }).onConflictDoNothing();
}
main().finally(async () => pool.end());
Zod 与 CI
TypeScript 只在编译时有用,API 输入需要运行时校验。drizzle-orm/zod 可以从 Drizzle schema 生成 Zod schema,再根据 API 输入做裁剪。
// db/validation.ts
import { createInsertSchema } from "drizzle-orm/zod";
import { z } from "zod";
import { posts } from "./schema";
export const createPostInputSchema = createInsertSchema(posts, {
slug: (schema) => schema.min(3).max(160).regex(/^[a-z0-9-]+$/),
title: (schema) => schema.min(1).max(160),
body: (schema) => schema.min(50),
})
.pick({ slug: true, title: true, body: true, authorId: true })
.extend({
categorySlugs: z.array(z.string().min(1).max(120)).min(1).max(5),
});
name: drizzle
on:
pull_request:
jobs:
db:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16
env:
POSTGRES_USER: app
POSTGRES_PASSWORD: app
POSTGRES_DB: app_test
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
env:
DATABASE_URL: postgresql://app:app@localhost:5432/app_test
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci
- run: npm run db:generate
- run: npm run db:check
- run: npm run db:migrate
- run: npm run db:seed
- run: npm run typecheck
- run: npm test
用例、坑与 CTA
| 用例 | 重点 | 给 Claude Code 的要求 |
|---|---|---|
| 内容 CMS | slug、发布状态、发布时间 index | 解释每个 index 服务哪个列表页 |
| SaaS 后台 | tenant、权限、审计日志 | 删除用户不能删除业务记录 |
| 课程平台 | 学习进度、权限、transaction | 进度更新和 audit log 必须一致 |
| 收益内容 | CTA 点击、商品路径、读完率 | 新增 analytics 表时不要破坏旧约束 |
Review 清单
在真实 PR 里,我会让 Claude Code 先按下面的清单自查,再由人类读 SQL。第一,schema 里的每个 uniqueIndex 和 index 都要能对应到一个页面或 API,不要为了“看起来完整”乱加 index。第二,所有 onDelete 都要写出业务理由,尤其是 User、Order、Payment、AuditLog 这类会影响收入和合规的数据。第三,migration SQL 必须作为 PR 重点,而不是藏在生成文件里。第四,seed 要在本地和 CI 中重复运行,确认第二次不会失败。第五,Zod schema 只校验入口,不替代数据库约束。第六,CI 通过以后仍要说明它只验证了空测试库,不代表生产旧数据一定安全。
这个清单看起来保守,但对 Claude Code 很有效。它会把“我已经生成代码”转成“我知道这个代码为什么可以上线”。数据库层最怕的是沉默的错误,尤其是多语言内容站、会员系统、课程平台和带 Gumroad 或咨询 CTA 的站点。一旦 slug、user、purchase、audit log 的关系被错误删除,SEO 和收入路径都会受影响。
常见坑包括:不读 migration SQL、seed 不能重复执行、transaction 里调用外部 API、把 Zod 当成数据库约束替代品、pagination 没有限制、让 Claude Code 顺手改了无关 migration。实际项目里,我会把这些检查写进 PR 模板,让 Claude Code 先自查,再由人类读 SQL。
如果你的团队希望把这套流程应用到真实仓库,可以从 ClaudeCodeLab 培训与咨询 开始。自己学习时,可以先比较 Supabase 集成 和 CI/CD 设置,再把本文的 prompt 改成自己的业务模型。
在一个小型博客 API 验证中,最有价值的不是让 Claude Code 更快写出 schema,而是让它解释生成的 SQL。第一次输出中删除规则过强,经过 onDelete、index、seed 幂等性的二次 review 后,migration 才变得可审。Drizzle ORM 的优势是轻、直观、接近 SQL;因此也更适合把判断过程留在代码和 PR 里。
免费 PDF: Claude Code 速查表
输入邮箱即可获取一页 PDF,整理常用命令、审查习惯和安全工作流。
我们会妥善保护你的信息,不发送垃圾邮件。
把 Claude Code 变成真正能带来结果的工作流
先领取中文说明的免费 PDF,再进入英文商品页选择合适的教材。如果你需要团队落地、流程设计或内容变现支持,也可以直接咨询。
关于作者
Masa
专注 Claude Code 实务流程、团队导入和内容转化的工程师。
相关文章
Claude Code权限安全阶梯:逐步放开访问而不失控
从只读到有限编辑、验证命令和部署检查的 Claude Code 权限升级流程。
Claude Code 小PR证据包:让小改动真正可审查
用差异、验证命令、公开URL、CTA路径和回滚说明,把Claude Code的小PR变得可审查。
Claude Code 提交前 Review Gate:同时检查差异、测试、公开 URL 和 CTA
提交前用 Claude Code 审查差异范围、build、公开 URL、Gumroad 链接、咨询 CTA、缺少测试和无关文件。