数据库与架构
使用 Drizzle ORM 进行类型安全的数据库操作 - 创建表、查询数据和管理迁移
数据库与架构
ProductReady 使用 Drizzle ORM 对 PostgreSQL 进行类型安全的数据库操作。本指南涵盖您需要了解的一切。
为什么选择 Drizzle? 最佳的 TypeScript 支持、类 SQL 语法、出色的性能,且轻量级(不像 Prisma 那样臃肿)。
快速概览
技术栈:
- ORM:Drizzle ORM
- 数据库:PostgreSQL
- 迁移:Drizzle Kit
- 查询构建器:类型安全且支持自动补全
常用命令:
pnpm db:generate # 从架构生成迁移
pnpm db:migrate # 运行迁移
pnpm db:push # 推送架构(仅开发环境!)
pnpm db:studio # 打开数据库 GUI
pnpm db:seed # 填充示例数据本地开发与多场景支持
ProductReady 支持多种数据库运行模式,适配从快速原型开发到企业级生产环境的各种场景。
无需启动 Docker! 🚀
在本地开发时,ProductReady 默认使用 PGLite (基于 WASM 的 PostgreSQL) 和 LmdB (高性能键值存储) 替代繁重的 Docker 容器。
- 数据库: PGLite (文件存储在
.data/pglite) - 缓存: LmdB (文件存储在
.data/lmdb) - 优势: 零配置、启动快、资源占用极低
只需运行:
pnpm dev系统会自动检测环境并使用本地文件数据库。
标准企业级架构 🏢
在生产环境中,我们使用标准的服务器架构以确保性能和可靠性。
- 数据库: PostgreSQL (Supabase, Neon, AWS RDS 等)
- 缓存: Redis (Upstash, AWS ElastiCache 等)
- 优势: 高可用、可扩展、备份恢复
配置 .env 环境变量:
DATABASE_URL="postgres://..."
REDIS_URL="redis://..."原生体验 🖥️
得益于 PGLite 和 LmdB 的嵌入式特性,ProductReady 可以轻松打包为 Electron 或 Tauri 桌面应用,无需用户安装额外的数据库服务。
- 数据隔离: 每个用户拥有独立的本地数据库文件
- 离线可用: 完全不依赖网络连接
- 隐私安全: 数据完全存储在用户本地设备
数据库架构
现有表
ProductReady 开箱即用包含以下表:
用户表 (users)
{
id: string (uuid)
email: string (unique)
name: string | null
emailVerified: boolean
image: string | null
createdAt: timestamp
updatedAt: timestamp
}会话表 (sessions)
{
id: string
userId: string (foreign key → users.id)
expiresAt: timestamp
ipAddress: string | null
userAgent: string | null
}任务表 (tasks)
{
id: number (serial)
title: string
description: string | null
status: 'pending' | 'in_progress' | 'completed'
priority: 'low' | 'medium' | 'high' | 'urgent'
userId: string (foreign key → users.id)
createdAt: timestamp
updatedAt: timestamp
}文章表 (posts)
{
id: number (serial)
title: string
content: string
published: timestamp | null
authorId: string (foreign key → users.id)
createdAt: timestamp
updatedAt: timestamp
}创建新表
让我们逐步添加一个 projects 表。
步骤 1:定义架构
创建 src/db/schema/projects.ts:
import { pgTable, serial, text, timestamp, varchar } from 'drizzle-orm/pg-core';
import { users } from './users';
export const projects = pgTable('projects', {
id: serial('id').primaryKey(),
name: varchar('name', { length: 255 }).notNull(),
description: text('description'),
status: text('status', {
enum: ['active', 'archived', 'completed']
}).default('active').notNull(),
ownerId: text('owner_id')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
});
// 从架构推断 TypeScript 类型
export type Project = typeof projects.$inferSelect;
export type NewProject = typeof projects.$inferInsert;架构类型:
$inferSelect- 读取时的类型(包含所有字段)$inferInsert- 创建时的类型(允许可选字段)
步骤 2:从索引导出
添加到 src/db/schema/index.ts:
export * from './users';
export * from './sessions';
export * from './tasks';
export * from './posts';
export * from './projects'; // ← 添加这行步骤 3:生成迁移
pnpm db:generate您将看到:
✓ Generated migration: 0001_create_projects_table.sql这会在 drizzle/migrations/ 中创建 SQL:
CREATE TABLE "projects" (
"id" serial PRIMARY KEY,
"name" varchar(255) NOT NULL,
"description" text,
"status" text DEFAULT 'active' NOT NULL,
"owner_id" text NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL,
CONSTRAINT "projects_owner_id_users_id_fk"
FOREIGN KEY ("owner_id") REFERENCES "users"("id") ON DELETE cascade
);步骤 4:运行迁移
pnpm db:migrate您的数据库现在有了 projects 表!✅
查询数据
查询(读取)
获取所有项目:
import { db } from '~/db';
import { projects } from '~/db/schema';
const allProjects = await db.select().from(projects);
// 类型:Project[]获取特定列:
const projectNames = await db
.select({
id: projects.id,
name: projects.name
})
.from(projects);
// 类型:{ id: number; name: string }[]import { eq, and, or, like, gte } from 'drizzle-orm';
// 单个条件
const activeProjects = await db
.select()
.from(projects)
.where(eq(projects.status, 'active'));
// 多个条件(AND)
const myActiveProjects = await db
.select()
.from(projects)
.where(
and(
eq(projects.ownerId, userId),
eq(projects.status, 'active')
)
);
// OR 条件
const urgentOrActive = await db
.select()
.from(projects)
.where(
or(
eq(projects.status, 'active'),
eq(projects.status, 'urgent')
)
);
// LIKE 搜索
const searchResults = await db
.select()
.from(projects)
.where(like(projects.name, '%设计%'));
// 日期比较
const recent = await db
.select()
.from(projects)
.where(gte(projects.createdAt, new Date('2024-01-01')));与用户表关联:
import { eq } from 'drizzle-orm';
import { users, projects } from '~/db/schema';
const projectsWithOwners = await db
.select({
id: projects.id,
name: projects.name,
owner: {
id: users.id,
name: users.name,
email: users.email,
},
})
.from(projects)
.leftJoin(users, eq(projects.ownerId, users.id));
// 结果类型:
// {
// id: number;
// name: string;
// owner: { id: string; name: string; email: string } | null;
// }[]const page = 1;
const pageSize = 20;
const paginatedProjects = await db
.select()
.from(projects)
.limit(pageSize)
.offset((page - 1) * pageSize)
.orderBy(desc(projects.createdAt));
// 获取总数
const [{ count }] = await db
.select({ count: count() })
.from(projects);
const totalPages = Math.ceil(count / pageSize);插入(创建)
const newProject = await db
.insert(projects)
.values({
name: '网站重设计',
description: '重新设计公司网站',
ownerId: userId,
status: 'active',
});const newProjects = await db
.insert(projects)
.values([
{ name: '项目 A', ownerId: userId },
{ name: '项目 B', ownerId: userId },
{ name: '项目 C', ownerId: userId },
]);const [createdProject] = await db
.insert(projects)
.values({ name: '新项目', ownerId: userId })
.returning();
console.log(createdProject.id); // 自动生成的 ID更新
import { eq } from 'drizzle-orm';
await db
.update(projects)
.set({
status: 'completed',
updatedAt: new Date(),
})
.where(eq(projects.id, projectId));// 标记所有为已归档
await db
.update(projects)
.set({ status: 'archived' })
.where(eq(projects.ownerId, userId));// 仅更新提供的字段
const updates = {
...(name && { name }),
...(description && { description }),
updatedAt: new Date(),
};
await db
.update(projects)
.set(updates)
.where(eq(projects.id, projectId));删除
import { eq } from 'drizzle-orm';
// 删除单个项目
await db
.delete(projects)
.where(eq(projects.id, projectId));
// 删除用户的所有项目
await db
.delete(projects)
.where(eq(projects.ownerId, userId));
// 带返回值的删除(获取已删除的行)
const [deleted] = await db
.delete(projects)
.where(eq(projects.id, projectId))
.returning();
console.log(deleted); // 已删除的项目级联删除:如果您在架构中设置了 onDelete: 'cascade',删除用户将自动删除其所有项目。
关系
定义表之间的关系以简化查询。
定义关系
在 src/db/schema/projects.ts 中:
import { relations } from 'drizzle-orm';
import { users } from './users';
export const projectsRelations = relations(projects, ({ one }) => ({
owner: one(users, {
fields: [projects.ownerId],
references: [users.id],
}),
}));在 src/db/schema/users.ts 中:
import { relations } from 'drizzle-orm';
import { projects } from './projects';
export const usersRelations = relations(users, ({ many }) => ({
projects: many(projects),
}));使用关系查询
// 获取项目及其所有者
const projectWithOwner = await db.query.projects.findFirst({
where: eq(projects.id, projectId),
with: {
owner: true, // 包含所有者数据
},
});
// 获取用户及其所有项目
const userWithProjects = await db.query.users.findFirst({
where: eq(users.id, userId),
with: {
projects: true, // 包含所有项目
},
});
// 嵌套关系
const projectWithOwnerAndPosts = await db.query.projects.findFirst({
with: {
owner: {
with: {
posts: true, // 所有者的文章
},
},
},
});迁移
生成迁移
架构更改后:
pnpm db:generate这会创建迁移 SQL 文件在 drizzle/migrations/。
运行迁移
pnpm db:migrate查看迁移
// drizzle/migrations/0001_create_projects.sql
CREATE TABLE "projects" (
"id" serial PRIMARY KEY,
"name" varchar(255) NOT NULL,
...
);迁移最佳实践
开发环境:使用 pnpm db:push 快速原型设计(跳过迁移)
生产环境:始终使用 pnpm db:generate + pnpm db:migrate 以获得可重复的部署
Drizzle Studio
可视化探索您的数据库:
pnpm db:studio这将在 https://local.drizzle.studio 打开:
- 📊 浏览所有表和数据
- ✏️ 编辑行
- 🔍 运行自定义查询
- 📈 查看关系图
事务
使用事务确保原子操作:
await db.transaction(async (tx) => {
// 创建项目
const [project] = await tx
.insert(projects)
.values({ name: '新项目', ownerId: userId })
.returning();
// 创建该项目的第一个任务
await tx
.insert(tasks)
.values({
title: '设置项目',
projectId: project.id,
userId,
});
// 如果任何查询失败,都会回滚
});高级查询
聚合
import { count, sum, avg, max, min } from 'drizzle-orm';
// 计数项目
const [{ total }] = await db
.select({ total: count() })
.from(projects);
// 按状态分组
const projectsByStatus = await db
.select({
status: projects.status,
count: count(),
})
.from(projects)
.groupBy(projects.status);
// 平均值、最大值、最小值
const stats = await db
.select({
avgPriority: avg(tasks.priority),
maxPriority: max(tasks.priority),
minPriority: min(tasks.priority),
})
.from(tasks);子查询
// 获取有任务的项目
const projectsWithTasks = await db
.select()
.from(projects)
.where(
exists(
db
.select()
.from(tasks)
.where(eq(tasks.projectId, projects.id))
)
);原始 SQL
当需要时:
import { sql } from 'drizzle-orm';
const result = await db.execute(
sql`SELECT * FROM projects WHERE name ILIKE ${'%设计%'}`
);类型安全
Drizzle 的最佳功能是完整的 TypeScript 支持:
// ✅ 类型安全 - TypeScript 知道所有字段
const project = await db.query.projects.findFirst();
console.log(project.name); // string
console.log(project.createdAt); // Date
// ❌ TypeScript 错误
console.log(project.nonExistentField); // 错误!
// ✅ 插入验证
await db.insert(projects).values({
name: '测试',
status: 'active', // ✅ 有效
});
await db.insert(projects).values({
name: '测试',
status: 'invalid', // ❌ TypeScript 错误!
});连接池(重要!)
对于无服务器环境(Vercel、Netlify),使用连接池以避免"客户端过多"错误。
Neon
使用端口 6543(池化)而不是 5432:
# 之前
******host:5432/db
# 之后(池化)
******host:6543/dbSupabase
使用仪表板中的"连接池"连接字符串
Railway
默认启用池化
性能提示
1. 使用索引
import { index } from 'drizzle-orm/pg-core';
export const projects = pgTable('projects', {
// ... 字段
}, (table) => ({
// 为经常查询的字段添加索引
ownerIdx: index('owner_idx').on(table.ownerId),
statusIdx: index('status_idx').on(table.status),
}));2. 仅选择需要的列
// ❌ 慢 - 选择所有内容
const projects = await db.select().from(projects);
// ✅ 快 - 仅选择需要的
const projects = await db
.select({ id: projects.id, name: projects.name })
.from(projects);3. 使用 limit()
// 始终限制大查询
const projects = await db
.select()
.from(projects)
.limit(100);4. 批量插入
// ❌ 慢 - 多个查询
for (const item of items) {
await db.insert(projects).values(item);
}
// ✅ 快 - 单个查询
await db.insert(projects).values(items);常见模式
软删除
export const projects = pgTable('projects', {
// ... 其他字段
deletedAt: timestamp('deleted_at'),
});
// 软删除
await db
.update(projects)
.set({ deletedAt: new Date() })
.where(eq(projects.id, projectId));
// 仅查询未删除的
const activeProjects = await db
.select()
.from(projects)
.where(isNull(projects.deletedAt));时间戳
export const projects = pgTable('projects', {
// ... 其他字段
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
});
// 更新时更新 updatedAt
await db
.update(projects)
.set({
name: '新名称',
updatedAt: new Date(), // 手动设置
})
.where(eq(projects.id, projectId));UUID 主键
import { uuid } from 'drizzle-orm/pg-core';
export const projects = pgTable('projects', {
id: uuid('id').defaultRandom().primaryKey(),
// ... 其他字段
});故障排除
"relation does not exist"(关系不存在)
解决方案:运行迁移
pnpm db:migrate"column does not exist"(列不存在)
解决方案:生成并运行新迁移
pnpm db:generate
pnpm db:migrate类型错误
解决方案:架构更改后重新生成类型
pnpm db:generate
# 然后重启 TypeScript 服务器数据填充 (Seeding)
ProductReady 在 src/db/seed/main.ts 中包含了一个 seed 系统,用于填充演示数据。
运行 Seed
# 开发环境(使用 tsx)
pnpm db:seed
# 生产环境(使用预编译包)
pnpm db:seed:prod生产环境打包
为什么要打包 seed 脚本? 在生产 Docker 镜像中,我们使用 tsup 预编译 seed 脚本:
- 保护源码 - 镜像中只包含压缩后的 JS,不包含 TypeScript 源码
- 减小镜像体积 - 无需复制
src/domains、src/config或 workspace 包 - 更快执行 - 预打包的单文件无需 tsx 编译开销
seed 脚本在 Docker 构建时打包:
# 构建 seed 包(生成 dist/seed/main.mjs)
pnpm db:seed:buildDocker 镜像包含:
dist/seed/main.mjs- 打包后的 seed 脚本(约 500KB)src/db/migrations/- 仅 SQL 迁移文件
Docker 镜像不包含:
src/db/seed/- TypeScript 源文件src/domains/- 领域逻辑源码src/config/- 配置源码
在 Docker 中运行 Seed
# 在容器中运行 seed
docker exec <container> node apps/productready/dist/seed/main.mjs
# 或通过 docker-compose
docker-compose exec app node apps/productready/dist/seed/main.mjs最佳实践
✅ 应该做
- 使用事务处理相关操作
- 索引经常查询的字段
- 验证插入前的输入(使用 Zod)
- 限制查询结果
- 使用关系进行复杂查询
- 池化连接用于生产环境
❌ 不应该做
- 不要在循环中查询
- 不要选择不需要的数据
- 不要忘记生产环境的迁移
- 不要在客户端代码中暴露数据库凭据
- 不要跳过输入验证
下一步
- tRPC API 指南 - 构建类型安全的 API
- Authentication - 用户管理
- Deployment - 部署到生产环境
准备好构建了吗?使用 Drizzle 的类型安全数据库操作! 🚀