Tips & Tricks (更新: 2026/6/2)

用 Claude Code 实现 Drizzle ORM:PostgreSQL、迁移、Zod 与 CI

从 schema、migration、seed、transaction、Zod 到 CI,完整讲解如何用 Claude Code 实现 Drizzle ORM。

用 Claude Code 实现 Drizzle ORM:PostgreSQL、迁移、Zod 与 CI

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 docsDrizzle Kit docsTransactionsDrizzle 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 NULLON DELETE 是否太宽,uniqueindex 是否覆盖真实查询。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 的要求
内容 CMSslug、发布状态、发布时间 index解释每个 index 服务哪个列表页
SaaS 后台tenant、权限、审计日志删除用户不能删除业务记录
课程平台学习进度、权限、transaction进度更新和 audit log 必须一致
收益内容CTA 点击、商品路径、读完率新增 analytics 表时不要破坏旧约束

Review 清单

在真实 PR 里,我会让 Claude Code 先按下面的清单自查,再由人类读 SQL。第一,schema 里的每个 uniqueIndexindex 都要能对应到一个页面或 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 里。

#Claude Code #Drizzle ORM #database #TypeScript #SQL
免费

免费 PDF: Claude Code 速查表

输入邮箱即可获取一页 PDF,整理常用命令、审查习惯和安全工作流。

我们会妥善保护你的信息,不发送垃圾邮件。

把 Claude Code 变成真正能带来结果的工作流

先领取中文说明的免费 PDF,再进入英文商品页选择合适的教材。如果你需要团队落地、流程设计或内容变现支持,也可以直接咨询。

Masa

关于作者

Masa

专注 Claude Code 实务流程、团队导入和内容转化的工程师。