ProductReadyProductReady

数据库与架构

使用 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/db

Supabase

使用仪表板中的"连接池"连接字符串

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 脚本:

  1. 保护源码 - 镜像中只包含压缩后的 JS,不包含 TypeScript 源码
  2. 减小镜像体积 - 无需复制 src/domainssrc/config 或 workspace 包
  3. 更快执行 - 预打包的单文件无需 tsx 编译开销

seed 脚本在 Docker 构建时打包:

# 构建 seed 包(生成 dist/seed/main.mjs)
pnpm db:seed:build

Docker 镜像包含

  • 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)
  • 限制查询结果
  • 使用关系进行复杂查询
  • 池化连接用于生产环境

❌ 不应该做

  • 不要在循环中查询
  • 不要选择不需要的数据
  • 不要忘记生产环境的迁移
  • 不要在客户端代码中暴露数据库凭据
  • 不要跳过输入验证

下一步


准备好构建了吗?使用 Drizzle 的类型安全数据库操作! 🚀

On this page