mcpField
D
drizzle

drizzle

github.com/lobehub数据与数据库83.6k5.5k 次安装今天更新
代码仓库

关于 drizzle

面向使用 PostgreSQL 的代码库开发者的 Drizzle ORM 模式与查询风格指南,规定模式设计、迁移、类型安全和数据库查询规范。

主要功能

  1. 01PostgreSQL 模式约定
  2. 02ID、主键与索引模式
  3. 03JSONB 类型与字段文档规范
  4. 04Drizzle 查询构建器指南
  5. 05数据库模型测试要求

适用场景

  1. 01添加数据库表或模型
  2. 02设计主键与唯一性约束
  3. 03编写类型安全的 Drizzle 查询
  4. 04审查数据库模式迁移

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/lobehub/lobehub --skill drizzle

安装后在 Claude Code 中输入/drizzle确认已加载。

详细介绍

Drizzle ORM 模式风格指南

要添加模型或仓库? 请在同一个 PR 中一并提交对应的测试文件——packages/database/src/models/** 或 src/repositories/** 下的每个新文件,都需要有对应的 __tests__/<name>.test.ts。有关 getTestDB() 集成模式、用户隔离测试、BM25 describe.skipIf(!isServerDB) 守卫以及模式注意事项,请参阅 testing 技能(.agents/skills/testing/references/db-model-test.md)。CI 的覆盖率补丁门禁无法可靠地发现全新且未经测试的文件,因此这项工作由你负责。

配置

  • 配置:drizzle.config.ts
  • 模式:packages/database/src/schemas/
  • 迁移:packages/database/migrations/
  • 方言:postgresql,且 strict: true

辅助函数

位置:packages/database/src/schemas/_helpers.ts

  • timestamptz(name):带时区的时间戳
  • createdAt()、updatedAt()、accessedAt():标准时间戳列
  • timestamps:包含以上三项的对象,便于展开

命名约定

  • 表:复数蛇形命名(users、session_groups)
  • 列:蛇形命名(user_id、created_at)
  • 新表:命名新表前,请先检查附近已有的表。保留既有的名词系列和后缀。例如,如果用户范围的表名为 user_xxx_logs,则工作区范围的对应表应为 workspace_xxx_logs,而不是 workspace_xxx_records 或其他新造的同义词。
// ✅ Good: follows the existing user/workspace table family (names are
// illustrative — swap `widget` for the real noun).
export const userWidgetLogs = pgTable('user_widget_logs', { ... });
export const workspaceWidgetLogs = pgTable('workspace_widget_logs', { ... });

// ❌ Bad: introduces a new suffix for the same concept.
export const workspaceWidgetRecords = pgTable('workspace_widget_records', { ... });

列定义

主键

不要使用自增主键(serial、bigserial、生成的 identity 列)。它们会在跨数据库迁移、还原和数据复制任务中造成序列状态问题。优先使用应用生成器(idGenerator、createNanoId)生成的文本 ID,或在内部表中使用 uuid。

当表通常自行生成 ID 时,请保留 $defaultFn(...)。调用方仍可显式传入 id;只有插入时未提供该值,才会运行默认函数。不要仅仅因为某个流程需要提供请求范围的 ID,就移除默认函数。

// ✅ Good: app-generated text ID; explicit inserts can still override it.
id: text('id')
  .primaryKey()
  .$defaultFn(() => idGenerator('agents'))
  .notNull(),

// ❌ Bad: sequence state is fragile across DB migrations and restores.
id: serial('id').primaryKey(),

ID 前缀有助于区分实体类型。内部表请使用 uuid。

新表不要使用复合主键。为每张表设置单列代理主键,并通过 uniqueIndex 保留业务唯一性。主键列不能为 nullable,因此如果唯一性范围之后增加了一个 nullable 维度,就必须拆除并重建复合主键——ai_providers / ai_models 按工作区划分范围时就发生了这种情况(迁移 0110–0111 将它们的复合主键替换为代理 _id,并添加了部分唯一索引)。唯一索引仍可作为 onConflictDoUpdate upsert 的仲裁依据。

// ✅ Good: surrogate PK; uniqueness scope can evolve without a PK rebuild.
export const workspaceUserSettings = pgTable(
  'workspace_user_settings',
  {
    id: uuid('id').defaultRandom().notNull().primaryKey(),
    workspaceId: text('workspace_id').references(() => workspaces.id, { onDelete: 'cascade' }).notNull(),
    userId: text('user_id').references(() => users.id, { onDelete: 'cascade' }).notNull(),
    ...timestamps,
  },
  (t) => [uniqueIndex('workspace_user_settings_workspace_id_user_id_unique').on(t.workspaceId, t.userId)],
);

// ❌ Bad: locked to exactly these columns; adding a nullable scope column
// (workspaceId, deviceId, …) later forces a full PK rebuild migration.
(t) => [primaryKey({ columns: [t.workspaceId, t.userId] })],

现有复合主键属于遗留设计——除非它们妨碍范围变更,否则不要改动;如需迁移,请采用 0110–0111 的方式。

外键
userId: text('user_id')
  .references(() => users.id, { onDelete: 'cascade' })
  .notNull(),
时间戳
...timestamps,  // Spread from _helpers.ts
可选值和 undefined 值

不要为缺失值引入人为的哨兵字符串,例如 unknown,除非该领域已经明确定义了这种状态,且现有代码始终如一地使用它。优先使用 nullable 列、可选的 TypeScript 字段,或者在值确实缺失时使用单独且具体的状态枚举。

// ✅ Good: absent until a custom-execution attempt actually runs
// (packages/database/src/schemas/agentIntervention.ts).
export const AGENT_INTERVENTION_CUSTOM_EXECUTION_STATES = ['pending', 'executing', 'completed'] as const;
export type AgentInterventionCustomExecutionState =
  (typeof AGENT_INTERVENTION_CUSTOM_EXECUTION_STATES)[number];

customExecutionState: text('custom_execution_state').$type<AgentInterventionCustomExecutionState>(),

// ❌ Bad: invents a new state that callers now need to handle everywhere.
export type AgentInterventionCustomExecutionState = 'pending' | 'executing' | 'completed' | 'unknown';

customExecutionState: text('custom_execution_state')
  .$type<AgentInterventionCustomExecutionState>()
  .notNull()
  .default('unknown');
数据库枚举

默认不要使用 PostgreSQL/Drizzle pgEnum。数据库枚举难以安全演进:添加成员需要迁移,删除或重命名成员也很麻烦,而且部署顺序会更脆弱。

对于产品或业务状态,使用 text() 或 varchar(),并通过 $type<...>() 指定 TypeScript 值类型。将这些仅用于 TS 的值类型放在领域/共享类型模块中,然后导入模式。在云 DB 模式中,这通常指 cloudDB/types.ts。

不要将现有 DB 枚举照搬为范例。应将其视为遗留设计或经过明确审查的例外。如果认为有必要新增 pgEnum,请先停下来说明为什么该值集合实际上不可变,以及为什么迁移成本可以接受。

字段说明

对于仅凭名称无法明确含义的列,请在 schema 字段上添加 JSDoc。若能说明存储值或写入该值的生命周期节点,请附上具体示例。这一点对外部 ID、生命周期状态、反规范化快照、JSONB 信号,以及名称可能指请求 ID 或持久化行 ID 的字段尤为重要。

// ✅ Good: explain the table's business object first, then only document
// non-obvious lifecycle fields
// (packages/database/src/schemas/agentIntervention.ts).
/**
 * Private resolution/outbox records. Unlike `agent_interventions`, this table
 * may retain user-edited arguments so a claimed decision can be delivered
 * reliably after the app disconnects. It is never a notification payload.
 */
export const agentInterventionResolutions = pgTable('agent_intervention_resolutions', {
  /** Owner of the durable operation, distinct from the resolving actor. */
  userId: text('user_id').references(() => users.id, { onDelete: 'cascade' }).notNull(),

  /** Private executor result; never selected into Review or notification DTOs. */
  customExecutionResult: jsonb('custom_execution_result').$type<AgentInterventionCustomExecutionResult>(),

  /** Monotonic lease attempt, starting at zero while pending. */
  customExecutionAttempt: integer('custom_execution_attempt'),
});

// ❌ Bad: comments restate obvious column names without adding domain meaning.
/** User email */
email: text('email'),
JSONB 类型

避免为 schema 列使用 Record<string, unknown> 或类似宽泛的 JSONB 类型。请定义描述预期 JSON 形状的具体接口,即使其中大多数属性都是可选的。这样可以确保调用方、迁移和审查查询遵循相同的数据契约。

// packages/types/src/agent/agentIntervention.ts +
// packages/database/src/schemas/agentIntervention.ts
interface AgentInterventionCustomExecutionResult {
  content: string;
  pluginState: Record<string, unknown>;
}

customExecutionResult: jsonb('custom_execution_result').$type<AgentInterventionCustomExecutionResult>(),
// ❌ Bad: hides the contract and makes downstream access untyped.
customExecutionResult: jsonb('custom_execution_result').$type<Record<string, unknown>>(),

JSONB 列的类型定义宽泛,往往意味着存在更深层的问题:该列是推测性地预留的(“供未来扩展”),但实际上没有任何代码会写入它。不要为假设的未来需求添加 metadata / extra JSONB 列——只有在具体写入逻辑随之上线时,列才有存在的价值。审查时若发现此类列,应删除该列,而不是为不存在的数据虚构接口;等实际需求出现后,再添加类型定义完善的列。

索引
// Return array (object style deprecated)
(t) => [uniqueIndex('client_id_user_id_unique').on(t.clientId, t.userId)],

类型推断

export const insertAgentSchema = createInsertSchema(agents);
export type NewAgent = typeof agents.$inferInsert;
export type AgentItem = typeof agents.$inferSelect;

示例模式

export const agents = pgTable(
  'agents',
  {
    id: text('id')
      .primaryKey()
      .$defaultFn(() => idGenerator('agents'))
      .notNull(),
    slug: varchar('slug', { length: 100 })
      .$defaultFn(() => randomSlug(4))
      .unique(),
    userId: text('user_id')
      .references(() => users.id, { onDelete: 'cascade' })
      .notNull(),
    clientId: text('client_id'),
    chatConfig: jsonb('chat_config').$type<LobeAgentChatConfig>(),
    ...timestamps,
  },
  (t) => [uniqueIndex('client_id_user_id_unique').on(t.clientId, t.userId)],
);

常见模式

联结表(多对多)

上述代理主键规则同样适用于联结表——成对唯一性应由 uniqueIndex 实现,而不是复合主键(许多现有联结表仍使用复合主键;这是历史遗留做法,不是模板):

export const agentsKnowledgeBases = pgTable(
  'agents_knowledge_bases',
  {
    id: uuid('id').defaultRandom().notNull().primaryKey(),
    agentId: text('agent_id')
      .references(() => agents.id, { onDelete: 'cascade' })
      .notNull(),
    knowledgeBaseId: text('knowledge_base_id')
      .references(() => knowledgeBases.id, { onDelete: 'cascade' })
      .notNull(),
    userId: text('user_id')
      .references(() => users.id, { onDelete: 'cascade' })
      .notNull(),
    enabled: boolean('enabled').default(true),
    ...timestamps,
  },
  (t) => [
    uniqueIndex('agents_knowledge_bases_agent_id_knowledge_base_id_unique').on(
      t.agentId,
      t.knowledgeBaseId,
    ),
  ],
);

查询风格

始终使用 db.select() 构建器 API。切勿使用 db.query.* 关系 API(findMany、findFirst、with:)。

关系 API 会生成复杂的横向联接,其中包含 json_build_array,既脆弱又难以调试。

查询单行
// ✅ Good
const [result] = await this.db.select().from(agents).where(eq(agents.id, id)).limit(1);
return result;

// ❌ Bad: relational API
return this.db.query.agents.findFirst({
  where: eq(agents.id, id),
});
使用 JOIN 查询
// ✅ Good: explicit select + leftJoin
const rows = await this.db
  .select({
    runId: agentEvalRunTopics.runId,
    score: agentEvalRunTopics.score,
    testCase: agentEvalTestCases,
    topic: topics,
  })
  .from(agentEvalRunTopics)
  .leftJoin(agentEvalTestCases, eq(agentEvalRunTopics.testCaseId, agentEvalTestCases.id))
  .leftJoin(topics, eq(agentEvalRunTopics.topicId, topics.id))
  .where(eq(agentEvalRunTopics.runId, runId))
  .orderBy(asc(agentEvalRunTopics.createdAt));

// ❌ Bad: relational API with `with:`
return this.db.query.agentEvalRunTopics.findMany({
  where: eq(agentEvalRunTopics.runId, runId),
  with: { testCase: true, topic: true },
});
使用聚合查询
// ✅ Good: select + leftJoin + groupBy
const rows = await this.db
  .select({
    id: agentEvalDatasets.id,
    name: agentEvalDatasets.name,
    testCaseCount: count(agentEvalTestCases.id).as('testCaseCount'),
  })
  .from(agentEvalDatasets)
  .leftJoin(agentEvalTestCases, eq(agentEvalDatasets.id, agentEvalTestCases.datasetId))
  .groupBy(agentEvalDatasets.id);
原始 SQL 和高级查询

只要使用 select、insert().select()、update().from()、联接、CTE 和 groupBy 能清晰地表达查询,就优先使用 Drizzle 构建器;这样表和列的引用会与 schema 保持关联,变更也会以 TypeScript 错误的形式显现。在构建器中,对于缺少辅助方法的功能(JSON 路径、类型转换、聚合、CASE、NOW()),可以使用表达式级别的 sql<T>。行锁是子句,而非表达式——请使用 .for('update'),不要使用原始 FOR UPDATE。

仅当空值处理属于必要的数据库语义(可为空的 JSONB 追加/合并、“保留第一个非空值”)时,才使用 COALESCE。不要在普通 upsert 标量字段中到处使用 COALESCE(excluded.col, current.col),只为避免构造更新对象——只根据已定义的值构建 set,并将剩余 SQL 封装在命名辅助函数中(appendJsonbArray、mergeJsonbObject、keepFirstValue),让方法体现业务意图,而非 SQL 细节。

// ✅ Scalars included only when present; SQL hidden behind a named helper.
// (`userWidgetLogs` is illustrative — swap for the real table.)
const updateValues = compactUndefined({
  email: record.email ?? undefined,
  ip: record.ip ?? undefined,
});
await db.insert(userWidgetLogs).values(values).onConflictDoUpdate({
  set: { ...updateValues, events: appendJsonbArray(userWidgetLogs.events, event), updatedAt: now },
  target: userWidgetLogs.id,
});

// ❌ Every scalar becomes SQL plumbing.
set: {
  email: sql`COALESCE(excluded.email, ${userWidgetLogs.email})`,
  ip: sql`COALESCE(excluded.ip, ${userWidgetLogs.ip})`,
}

重构原始 SQL 时:

  • 在对延迟敏感的路径上保留查询形状。如果原始 SQL 只需一次往返,就不要仅为移除 execute 而将其拆分成多个按深度查询。
  • 对 Drizzle 能表达的多步骤单次往返写入,使用 $with(...) + insert().select() / update().from()。
  • 不要依赖 execute<MyRow>(sql...) 来保证安全——它只能为行提供类型,无法确保所选列与 schema 变更保持同步。
  • 如果只有 Drizzle 无法表达的 PostgreSQL 功能可行,就保留原始 SQL 并收紧其实现:在插值中引用 schema、明确用户范围、使用精简的行接口,并添加回归测试。

递归 CTE 是应保留原始 SQL 的典型场景——没有简洁的 WITH RECURSIVE 构建器,改写还会增加按深度查询的往返:

interface TaskTreeRow {
  id: string;
  parent_task_id: string | null;
}

// execute<T> acceptable: no clean WITH RECURSIVE builder. Keep schema refs in the
// interpolations and scope every leg to the user.
const { rows } = await db.execute<TaskTreeRow>(sql`
  WITH RECURSIVE task_tree AS (
    SELECT ${tasks.id}, ${tasks.parentTaskId}
    FROM ${tasks}
    WHERE ${tasks.id} = ${rootTaskId} AND ${tasks.createdByUserId} = ${userId}
    UNION ALL
    SELECT ${tasks.id}, ${tasks.parentTaskId}
    FROM ${tasks}
    JOIN task_tree ON ${tasks.parentTaskId} = task_tree.id
    WHERE ${tasks.createdByUserId} = ${userId}
  )
  SELECT * FROM task_tree
`);
一对多(分开查询)

需要同时获取父记录及其子记录时,请使用两次查询,而不是关系 API 的 with::

// ✅ Good: two simple queries
const [dataset] = await this.db
  .select()
  .from(agentEvalDatasets)
  .where(eq(agentEvalDatasets.id, id))
  .limit(1);

if (!dataset) return undefined;

const testCases = await this.db
  .select()
  .from(agentEvalTestCases)
  .where(eq(agentEvalTestCases.datasetId, id))
  .orderBy(asc(agentEvalTestCases.sortOrder));

return { ...dataset, testCases };

数据库迁移

详细的迁移指南请参阅 db-migrations 技能。

评论

支持 Markdown