Drizzle ORM
Drizzle ORM 是一个面向 TypeScript 的轻量级 ORM / SQL 查询构建器。它的核心思路不是隐藏 SQL,而是在尽可能贴近 SQL 的前提下,提供强类型推导、关系查询、迁移管理与多数据库驱动支持。
在 Nest 中,Drizzle 很适合以下场景:
- 你希望保留接近原生 SQL 的表达能力,而不是完全依赖 Active Record / Repository 抽象。
- 你希望 schema、查询和返回类型都由 TypeScript 推导出来。
- 你需要 PostgreSQL、MySQL 或 SQLite 这类传统关系型数据库,并希望保留良好的可测试性与模块化结构。
提示
drizzle-orm 和 drizzle-kit 都是第三方库,并非由 Nest 核心团队维护。如果你遇到库本身的问题,应在其官方仓库中反馈。
安装
下面以 PostgreSQL 为例:
$ npm i drizzle-orm pg
$ npm i -D drizzle-kit如果你使用的是其他数据库,请替换底层驱动:
- MySQL:
npm i drizzle-orm mysql2 - SQLite:
npm i drizzle-orm better-sqlite3
推荐的项目结构
对于大多数 Nest 项目,建议将数据库基础设施与业务模块分离。下面这个结构只是为了演示 Drizzle 的完整用法,不是要求你必须在真实项目里保留 users / posts 这套 demo 模块命名:
src
├── app.module.ts
├── database
│ ├── drizzle.module.ts
│ ├── drizzle.service.ts
│ └── schema.ts
├── posts
│ ├── dto
│ │ ├── create-post.dto.ts
│ │ ├── list-posts.dto.ts
│ │ └── update-post.dto.ts
│ ├── posts.controller.ts
│ ├── posts.module.ts
│ └── posts.service.ts
└── users
├── dto
│ ├── create-user.dto.ts
│ ├── list-users.dto.ts
│ └── update-user.dto.ts
├── users.controller.ts
├── users.module.ts
└── users.service.ts
drizzle.config.ts定义 schema
Drizzle 的核心是 schema。推荐把所有表、关系和可复用类型统一声明在 src/database/schema.ts 中;如果项目较大,也可以按领域拆成多个 schema 文件再聚合导出。
下面定义一个常见的 users / posts 示例。它的作用是演示 Drizzle 在 Nest 中如何组织 schema、关系查询、CRUD 与事务,而不是规定你的业务模块一定要这样命名:
// schema.ts
import { relations, sql } from 'drizzle-orm';
import {
index,
integer,
pgTable,
serial,
text,
timestamp,
uniqueIndex,
} from 'drizzle-orm/pg-core';
export const users = pgTable(
'users',
{
id: serial('id').primaryKey(),
email: text('email').notNull(),
name: text('name').notNull(),
createdAt: timestamp('created_at', { withTimezone: true })
.defaultNow()
.notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true })
.defaultNow()
.notNull(),
},
(table) => ({
emailUnique: uniqueIndex('users_email_unique').on(table.email),
createdAtIdx: index('users_created_at_idx').on(table.createdAt),
}),
);
export const posts = pgTable(
'posts',
{
id: serial('id').primaryKey(),
userId: integer('user_id')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
title: text('title').notNull(),
content: text('content'),
published: integer('published').default(0).notNull(),
createdAt: timestamp('created_at', { withTimezone: true })
.defaultNow()
.notNull(),
},
(table) => ({
userIdIdx: index('posts_user_id_idx').on(table.userId),
publishedIdx: index('posts_published_idx').on(table.published),
}),
);
export const usersRelations = relations(users, ({ many }) => ({
posts: many(posts),
}));
export const postsRelations = relations(posts, ({ one }) => ({
author: one(users, {
fields: [posts.userId],
references: [users.id],
}),
}));
export type User = typeof users.$inferSelect;
export type NewUser = typeof users.$inferInsert;
export type Post = typeof posts.$inferSelect;提示
如果你使用 MySQL 或 SQLite,只需要把 drizzle-orm/pg-core 替换为对应方言的 mysql-core 或 sqlite-core,表定义思路保持不变。
配置 Drizzle Kit
drizzle-kit 负责 schema diff、迁移生成、迁移执行以及从现有数据库反向生成 schema。
// drizzle.config.ts
import { defineConfig } from 'drizzle-kit';
export default defineConfig({
dialect: 'postgresql',
schema: './src/database/schema.ts',
out: './drizzle',
dbCredentials: {
url: process.env.DATABASE_URL!,
},
verbose: true,
strict: true,
});你通常还会在 package.json 里加入这些脚本:
{
"scripts": {
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate",
"db:push": "drizzle-kit push",
"db:pull": "drizzle-kit pull",
"db:studio": "drizzle-kit studio"
}
}几种常见工作流如下:
db:push:适合原型阶段,直接把 schema 变更推送到数据库。db:generate+db:migrate:更适合团队协作和生产环境,先生成 SQL 迁移文件,再执行迁移。db:pull:适合接入已有数据库,先从数据库结构反向生成 Drizzle schema。
警告
如果项目进入团队协作或生产环境,通常更推荐 generate + migrate,因为它会保留可审查、可追踪、可回滚的 SQL 迁移历史。
创建 DrizzleModule
在 Nest 中,一个比较稳妥的做法是把数据库连接与 Drizzle 实例封装在独立模块里,并在应用关闭时释放连接池。
// drizzle.service.ts
import { Injectable, OnModuleDestroy } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { drizzle } from 'drizzle-orm/node-postgres';
import { NodePgDatabase } from 'drizzle-orm/node-postgres';
import { Pool } from 'pg';
import * as schema from './schema';
@Injectable()
export class DrizzleService implements OnModuleDestroy {
private readonly pool: Pool;
readonly db: NodePgDatabase<typeof schema>;
constructor(private readonly configService: ConfigService) {
this.pool = new Pool({
connectionString: this.configService.getOrThrow<string>('DATABASE_URL'),
});
this.db = drizzle({
client: this.pool,
schema,
});
}
async onModuleDestroy() {
await this.pool.end();
}
}// drizzle.module.ts
import { Global, Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { DrizzleService } from './drizzle.service';
@Global()
@Module({
imports: [ConfigModule],
providers: [DrizzleService],
exports: [DrizzleService],
})
export class DrizzleModule {}在根模块中导入它。真实项目里你只需要导入自己的业务模块;这里的 UsersModule / PostsModule 只是用于后文演示增删改查、关联读取和事务:
// app.module.ts
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { DrizzleModule } from './database/drizzle.module';
import { ArticlesModule } from './articles/articles.module';
import { AccountsModule } from './accounts/accounts.module';
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
}),
DrizzleModule,
ArticlesModule,
AccountsModule,
],
})
export class AppModule {}CRUD 与事务示例说明
下面开始出现的 users / posts 代码,只是为了把常见数据库场景一次讲全:
- 单条创建
- 批量写入
- 列表分页
- 条件过滤
- 详情读取
- 更新
- 删除
- upsert
- 关系查询
- 事务
它们应被理解为“可迁移的写法模板”,而不是“必须照抄的模块结构”。
在真实项目中,更推荐你把这些查询模式迁移到自己的领域模块里,例如:
AccountsServiceOrdersServiceBillingServiceInventoryService
而不是真的维护一个 UsersModule / PostsModule demo。
Demo:用户模块
下面通过一个完整的 UsersModule 演示 Drizzle 在 Nest 中的常规 CRUD 写法。
// users.module.ts
import { Module } from '@nestjs/common';
import { UsersController } from './users.controller';
import { UsersService } from './users.service';
@Module({
controllers: [UsersController],
providers: [UsersService],
})
export class UsersModule {}Demo DTO
如果你使用的是 Nest 默认的 class-validator 工作流,可以像下面这样定义 DTO:
// dto/create-user.dto.ts
import { IsEmail, IsNotEmpty, IsString, MaxLength } from 'class-validator';
export class CreateUserDto {
@IsEmail()
email: string;
@IsString()
@IsNotEmpty()
@MaxLength(50)
name: string;
}// dto/update-user.dto.ts
import { IsEmail, IsOptional, IsString, MaxLength } from 'class-validator';
export class UpdateUserDto {
@IsOptional()
@IsEmail()
email?: string;
@IsOptional()
@IsString()
@MaxLength(50)
name?: string;
}// dto/list-users.dto.ts
import { Transform } from 'class-transformer';
import { IsInt, IsOptional, IsString, Max, Min } from 'class-validator';
export class ListUsersDto {
@IsOptional()
@Transform(({ value }) => Number(value ?? 1))
@IsInt()
@Min(1)
page = 1;
@IsOptional()
@Transform(({ value }) => Number(value ?? 20))
@IsInt()
@Min(1)
@Max(100)
pageSize = 20;
@IsOptional()
@IsString()
keyword?: string;
}通用 CRUD 写法模式
// users.service.ts
import { Injectable, NotFoundException } from '@nestjs/common';
import { and, desc, eq, ilike, sql } from 'drizzle-orm';
import { DrizzleService } from '../database/drizzle.service';
import { posts, users } from '../database/schema';
import { CreateUserDto } from './dto/create-user.dto';
import { ListUsersDto } from './dto/list-users.dto';
import { UpdateUserDto } from './dto/update-user.dto';
@Injectable()
export class UsersService {
constructor(private readonly drizzle: DrizzleService) {}
async create(dto: CreateUserDto) {
const [user] = await this.drizzle.db
.insert(users)
.values(dto)
.returning();
return user;
}
async createMany(items: CreateUserDto[]) {
return this.drizzle.db.insert(users).values(items).returning();
}
async findAll(query: ListUsersDto) {
const { page, pageSize, keyword } = query;
const offset = (page - 1) * pageSize;
const where = keyword
? and(ilike(users.name, `%${keyword}%`))
: undefined;
const [items, total] = await Promise.all([
this.drizzle.db
.select({
id: users.id,
email: users.email,
name: users.name,
createdAt: users.createdAt,
})
.from(users)
.where(where)
.orderBy(desc(users.createdAt))
.limit(pageSize)
.offset(offset),
this.drizzle.db.$count(users, where),
]);
return {
items,
total,
page,
pageSize,
};
}
async findOne(id: number) {
const [user] = await this.drizzle.db
.select()
.from(users)
.where(eq(users.id, id))
.limit(1);
if (!user) {
throw new NotFoundException(`User #${id} not found`);
}
return user;
}
async update(id: number, dto: UpdateUserDto) {
const [updated] = await this.drizzle.db
.update(users)
.set({
...dto,
updatedAt: sql`now()`,
})
.where(eq(users.id, id))
.returning();
if (!updated) {
throw new NotFoundException(`User #${id} not found`);
}
return updated;
}
async remove(id: number) {
const [deleted] = await this.drizzle.db
.delete(users)
.where(eq(users.id, id))
.returning({
id: users.id,
email: users.email,
});
if (!deleted) {
throw new NotFoundException(`User #${id} not found`);
}
return deleted;
}
async upsertByEmail(dto: CreateUserDto) {
const [user] = await this.drizzle.db
.insert(users)
.values(dto)
.onConflictDoUpdate({
target: users.email,
set: {
name: dto.name,
updatedAt: sql`now()`,
},
})
.returning();
return user;
}
async findUserWithPosts(id: number) {
const user = await this.drizzle.db.query.users.findFirst({
where: eq(users.id, id),
with: {
posts: {
columns: {
id: true,
title: true,
published: true,
createdAt: true,
},
orderBy: [desc(posts.createdAt)],
},
},
});
if (!user) {
throw new NotFoundException(`User #${id} not found`);
}
return user;
}
}上面的服务已经覆盖了绝大多数日常场景。真正重要的不是 UsersService 这个名字,而是下面这些通用模式:
create():创建单条记录createMany():批量插入findAll():列表、关键字过滤、分页、排序findOne():详情查询update():局部更新remove():物理删除upsertByEmail():按唯一键执行 upsertfindUserWithPosts():关系查询
提示
在 PostgreSQL 和 SQLite 中,insert / update / delete 可以直接使用 .returning()。如果你使用 MySQL,则通常需要改用 $returningId(),或者执行额外一次查询来拿回写入后的记录。
这些模式如何迁移到真实业务模块
上面的 demo 可以直接映射到真实业务中:
create():创建订单、商品、分类、租户、标签createMany():批量导入用户、批量生成任务、批量插入字典数据findAll():后台列表页、管理台筛选页、导出预览页findOne():详情页、编辑页、审批页update():局部修改、状态更新、补充字段remove():物理删除或你自己的软删除封装upsertByEmail():幂等写入、第三方同步、外部 webhook 落库findUserWithPosts():带子资源的详情页、聚合读取、嵌套接口返回
如果你在自己的项目里写的是 OrdersService,完全可以照搬这种结构:
async findAll(query: ListOrdersDto) {}
async findOne(id: string) {}
async create(dto: CreateOrderDto) {}
async update(id: string, dto: UpdateOrderDto) {}
async remove(id: string) {}重点不是“模块名”,而是:
- DTO 负责入参校验
- service 负责查询拼装
- schema 负责数据库约束
- migration 负责结构演进
Demo 控制器
// users.controller.ts
import {
Body,
Controller,
Delete,
Get,
Param,
ParseIntPipe,
Patch,
Post,
Query,
} from '@nestjs/common';
import { CreateUserDto } from './dto/create-user.dto';
import { ListUsersDto } from './dto/list-users.dto';
import { UpdateUserDto } from './dto/update-user.dto';
import { UsersService } from './users.service';
@Controller('users')
export class UsersController {
constructor(private readonly usersService: UsersService) {}
@Post()
create(@Body() dto: CreateUserDto) {
return this.usersService.create(dto);
}
@Post('batch')
createMany(@Body() items: CreateUserDto[]) {
return this.usersService.createMany(items);
}
@Get()
findAll(@Query() query: ListUsersDto) {
return this.usersService.findAll(query);
}
@Get(':id')
findOne(@Param('id', ParseIntPipe) id: number) {
return this.usersService.findOne(id);
}
@Get(':id/posts')
findUserWithPosts(@Param('id', ParseIntPipe) id: number) {
return this.usersService.findUserWithPosts(id);
}
@Patch(':id')
update(
@Param('id', ParseIntPipe) id: number,
@Body() dto: UpdateUserDto,
) {
return this.usersService.update(id, dto);
}
@Delete(':id')
remove(@Param('id', ParseIntPipe) id: number) {
return this.usersService.remove(id);
}
}Demo:文章模块
当项目里存在明确的主从关系时,可以像这里的 UsersModule 和 PostsModule 一样拆开演示。但在真实项目中,你应该按自己的业务边界拆分,而不是按教程里的 demo 资源名拆分。
// posts.module.ts
import { Module } from '@nestjs/common';
import { PostsController } from './posts.controller';
import { PostsService } from './posts.service';
@Module({
controllers: [PostsController],
providers: [PostsService],
})
export class PostsModule {}Demo DTO
// dto/create-post.dto.ts
import {
IsBoolean,
IsInt,
IsNotEmpty,
IsOptional,
IsString,
MaxLength,
Min,
} from 'class-validator';
export class CreatePostDto {
@IsInt()
@Min(1)
userId: number;
@IsString()
@IsNotEmpty()
@MaxLength(120)
title: string;
@IsOptional()
@IsString()
content?: string;
@IsOptional()
@IsBoolean()
published?: boolean;
}// dto/update-post.dto.ts
import {
IsBoolean,
IsInt,
IsOptional,
IsString,
MaxLength,
Min,
} from 'class-validator';
export class UpdatePostDto {
@IsOptional()
@IsInt()
@Min(1)
userId?: number;
@IsOptional()
@IsString()
@MaxLength(120)
title?: string;
@IsOptional()
@IsString()
content?: string;
@IsOptional()
@IsBoolean()
published?: boolean;
}// dto/list-posts.dto.ts
import { Transform } from 'class-transformer';
import {
IsBoolean,
IsInt,
IsOptional,
IsString,
Max,
Min,
} from 'class-validator';
export class ListPostsDto {
@IsOptional()
@Transform(({ value }) => Number(value ?? 1))
@IsInt()
@Min(1)
page = 1;
@IsOptional()
@Transform(({ value }) => Number(value ?? 20))
@IsInt()
@Min(1)
@Max(100)
pageSize = 20;
@IsOptional()
@Transform(({ value }) =>
value === undefined ? undefined : Number(value),
)
@IsInt()
@Min(1)
userId?: number;
@IsOptional()
@Transform(({ value }) =>
value === undefined ? undefined : value === 'true' || value === true,
)
@IsBoolean()
published?: boolean;
@IsOptional()
@IsString()
keyword?: string;
}Demo:文章 CRUD 服务
下面的 PostsService 展示的是双模块场景下最常见的写法:列表筛选、关联作者校验、单条查询、更新、删除、按作者聚合查询。
// posts.service.ts
import {
Injectable,
BadRequestException,
NotFoundException,
} from '@nestjs/common';
import { and, desc, eq, ilike } from 'drizzle-orm';
import { DrizzleService } from '../database/drizzle.service';
import { posts, users } from '../database/schema';
import { CreatePostDto } from './dto/create-post.dto';
import { ListPostsDto } from './dto/list-posts.dto';
import { UpdatePostDto } from './dto/update-post.dto';
@Injectable()
export class PostsService {
constructor(private readonly drizzle: DrizzleService) {}
async create(dto: CreatePostDto) {
await this.ensureAuthorExists(dto.userId);
const [post] = await this.drizzle.db
.insert(posts)
.values({
userId: dto.userId,
title: dto.title,
content: dto.content ?? null,
published: dto.published ? 1 : 0,
})
.returning();
return post;
}
async findAll(query: ListPostsDto) {
const { page, pageSize, keyword, userId, published } = query;
const offset = (page - 1) * pageSize;
const filters = [
keyword ? ilike(posts.title, `%${keyword}%`) : undefined,
userId ? eq(posts.userId, userId) : undefined,
published === undefined
? undefined
: eq(posts.published, published ? 1 : 0),
].filter(Boolean);
const where = filters.length ? and(...filters) : undefined;
const [items, total] = await Promise.all([
this.drizzle.db
.select({
id: posts.id,
title: posts.title,
content: posts.content,
published: posts.published,
createdAt: posts.createdAt,
authorId: users.id,
authorName: users.name,
authorEmail: users.email,
})
.from(posts)
.innerJoin(users, eq(users.id, posts.userId))
.where(where)
.orderBy(desc(posts.createdAt))
.limit(pageSize)
.offset(offset),
this.drizzle.db.$count(posts, where),
]);
return {
items,
total,
page,
pageSize,
};
}
async findOne(id: number) {
const post = await this.drizzle.db.query.posts.findFirst({
where: eq(posts.id, id),
with: {
author: {
columns: {
id: true,
name: true,
email: true,
},
},
},
});
if (!post) {
throw new NotFoundException(`Post #${id} not found`);
}
return post;
}
async findByAuthor(userId: number) {
await this.ensureAuthorExists(userId);
return this.drizzle.db.query.posts.findMany({
where: eq(posts.userId, userId),
columns: {
id: true,
title: true,
content: true,
published: true,
createdAt: true,
},
orderBy: [desc(posts.createdAt)],
});
}
async update(id: number, dto: UpdatePostDto) {
if (dto.userId) {
await this.ensureAuthorExists(dto.userId);
}
const [updated] = await this.drizzle.db
.update(posts)
.set({
userId: dto.userId,
title: dto.title,
content: dto.content,
published:
dto.published === undefined ? undefined : dto.published ? 1 : 0,
})
.where(eq(posts.id, id))
.returning();
if (!updated) {
throw new NotFoundException(`Post #${id} not found`);
}
return updated;
}
async remove(id: number) {
const [deleted] = await this.drizzle.db
.delete(posts)
.where(eq(posts.id, id))
.returning({
id: posts.id,
title: posts.title,
});
if (!deleted) {
throw new NotFoundException(`Post #${id} not found`);
}
return deleted;
}
async publish(id: number) {
const [updated] = await this.drizzle.db
.update(posts)
.set({
published: 1,
})
.where(eq(posts.id, id))
.returning();
if (!updated) {
throw new NotFoundException(`Post #${id} not found`);
}
return updated;
}
private async ensureAuthorExists(userId: number) {
const [author] = await this.drizzle.db
.select({ id: users.id })
.from(users)
.where(eq(users.id, userId))
.limit(1);
if (!author) {
throw new BadRequestException(`Author #${userId} does not exist`);
}
}
}这部分补齐后,posts 也具备了完整的业务闭环。它的价值同样在于展示通用模式,而不是让你在真实系统里一定保留 posts 这个模块:
create():创建资源前先校验外键依赖是否存在findAll():列表、状态筛选、关键字检索、分页findOne():详情查询并附带关联对象findByAuthor():按上级实体、租户、分组、组织做聚合读取update():补丁式更新remove():删除或归档publish():把高频业务动作收敛成显式 service 方法
Demo 控制器
// posts.controller.ts
import {
Body,
Controller,
Delete,
Get,
Param,
ParseIntPipe,
Patch,
Post,
Query,
} from '@nestjs/common';
import { CreatePostDto } from './dto/create-post.dto';
import { ListPostsDto } from './dto/list-posts.dto';
import { UpdatePostDto } from './dto/update-post.dto';
import { PostsService } from './posts.service';
@Controller('posts')
export class PostsController {
constructor(private readonly postsService: PostsService) {}
@Post()
create(@Body() dto: CreatePostDto) {
return this.postsService.create(dto);
}
@Get()
findAll(@Query() query: ListPostsDto) {
return this.postsService.findAll(query);
}
@Get('author/:userId')
findByAuthor(@Param('userId', ParseIntPipe) userId: number) {
return this.postsService.findByAuthor(userId);
}
@Get(':id')
findOne(@Param('id', ParseIntPipe) id: number) {
return this.postsService.findOne(id);
}
@Patch(':id')
update(
@Param('id', ParseIntPipe) id: number,
@Body() dto: UpdatePostDto,
) {
return this.postsService.update(id, dto);
}
@Patch(':id/publish')
publish(@Param('id', ParseIntPipe) id: number) {
return this.postsService.publish(id);
}
@Delete(':id')
remove(@Param('id', ParseIntPipe) id: number) {
return this.postsService.remove(id);
}
}有了 users + posts 这两个 demo 模块之后,这篇秘籍就不再只是“单表 CRUD 示例”,而是把常见 Drizzle 用法都串起来了。但你在真实项目中应当提炼的是“写法模式”,而不是把 demo 目录原样搬进生产代码:
database目录负责 schema、连接和迁移- DTO / pipe / zod 负责输入边界
- service 负责 CRUD、分页、过滤、关联和事务
- controller 只负责 HTTP 接口编排
- 具体模块命名与拆分应由你的业务领域决定
真实项目里更推荐的组织方式
如果你不想让文档读起来像“教程项目脚手架”,可以把 Drizzle 理解成下面几层职责:
src
├── database
│ ├── drizzle.module.ts
│ ├── drizzle.service.ts
│ ├── schema
│ └── migrations
├── modules
│ ├── accounts
│ ├── orders
│ ├── billing
│ └── inventory
└── shared每个业务模块只关心:
- 它依赖哪些表
- 它暴露哪些 CRUD / query 方法
- 它是否需要事务
- 它是否需要读写分离、分页、聚合或关系查询
而不是关心“是不是照着 demo 的 users/posts 来建目录”。
事务
当一个业务操作需要跨多条语句保持原子性时,应使用事务。比如创建用户的同时写入一篇欢迎文章:
async createUserWithWelcomePost(dto: CreateUserDto) {
return this.drizzle.db.transaction(async (tx) => {
const [user] = await tx
.insert(users)
.values(dto)
.returning();
await tx.insert(posts).values({
userId: user.id,
title: '欢迎使用 Drizzle + Nest',
content: '这是系统自动创建的第一篇文章。',
published: 1,
});
return user;
});
}如果中间任一步失败,事务会自动回滚。Drizzle 也支持嵌套事务 / savepoint。
关系查询
Drizzle 的关系查询依赖于你在初始化时把 schema 传给 drizzle()。完成这一步后,就可以通过 db.query.<table> 直接获取嵌套结果,而不必自己手工映射 join 结果。
const usersWithPosts = await this.drizzle.db.query.users.findMany({
with: {
posts: true,
},
});如果你更偏向 SQL 风格,也可以直接写 join:
import { eq } from 'drizzle-orm';
const rows = await this.drizzle.db
.select({
userId: users.id,
userName: users.name,
postId: posts.id,
postTitle: posts.title,
})
.from(users)
.leftJoin(posts, eq(posts.userId, users.id));使用 drizzle-orm/zod 减少 DTO 重复
如果你希望从 schema 自动生成插入 / 更新校验规则,可以使用 Drizzle 官方提供的 drizzle-orm/zod:
$ npm i zod// user.schemas.ts
import { createInsertSchema, createUpdateSchema } from 'drizzle-orm/zod';
import { z } from 'zod';
import { users } from '../database/schema';
export const createUserSchema = createInsertSchema(users, {
email: (schema) => schema.email(),
name: (schema) => schema.min(2).max(50),
}).pick({
email: true,
name: true,
});
export const updateUserSchema = createUpdateSchema(users, {
email: (schema) => schema.email(),
name: (schema) => schema.min(2).max(50),
}).pick({
email: true,
name: true,
});
export type CreateUserInput = z.infer<typeof createUserSchema>;
export type UpdateUserInput = z.infer<typeof updateUserSchema>;这类方式特别适合以下场景:
- 你不想维护一套数据库 schema 和一套重复的 DTO 字段
- 你已经在项目里使用 Zod pipe、
nestjs-zod或自定义校验管道 - 你希望插入和更新的类型始终与数据库 schema 保持同步
物理删除与软删除
上面的 remove() 使用的是物理删除,适合日志型、临时型或关系清晰的业务表。对于用户、订单、账单等高价值数据,更推荐软删除:
// 在 schema 中新增 deletedAt
deletedAt: timestamp('deleted_at', { withTimezone: true }),await this.drizzle.db
.update(users)
.set({
deletedAt: sql`now()`,
updatedAt: sql`now()`,
})
.where(eq(users.id, id));后续所有查询都加上 isNull(users.deletedAt) 条件即可。
已有数据库项目如何接入
如果你的数据库已经存在,而不是从代码优先开始,可以使用:
$ npx drizzle-kit pull它会读取现有数据库结构,并生成 Drizzle schema。对于老项目迁移,这通常比手写 schema 更稳妥。
drizzle-kit 命令如何选
很多团队在刚接触 Drizzle 时,最容易混淆的不是查询写法,而是到底应该用 push、generate、migrate、pull、check、studio 中的哪一个。可以按下面的场景来选:
- 原型开发、个人项目、本地快速试错:优先
push - 正式项目、多人协作、需要审计 SQL 变更:优先
generate + migrate - 接手已有数据库、先让代码贴近现有库结构:优先
pull - 发布前检查迁移历史或 CI 校验:增加
check - 本地可视化查看和手工排查数据:使用
studio - 需要补充数据修复、回填、DDL 之外的特殊 SQL:使用自定义 migration
你可以把脚本进一步细化成:
{
"scripts": {
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate",
"db:push": "drizzle-kit push",
"db:pull": "drizzle-kit pull",
"db:check": "drizzle-kit check",
"db:studio": "drizzle-kit studio",
"db:generate:init": "drizzle-kit generate --name=init",
"db:generate:custom": "drizzle-kit generate --custom --name=manual-fix"
}
}场景一:从零开始的新项目
如果数据库也是跟着 Nest 项目一起从零启动,最推荐的流程是:
- 先写
schema.ts - 本地开发阶段可以用
db:push快速验证 - 一旦进入团队协作或测试环境,切到
db:generate+db:migrate - 所有 schema 变更都通过 PR 审查 migration SQL
典型流程如下:
$ pnpm db:generate --name add-user-status
$ pnpm db:migrate这种方式的优点是:
- schema 仍由 TypeScript 主导
- 最终落库动作通过 SQL migration 执行
- 迁移历史可审查、可回放、可追踪
- 更适合灰度、回归测试和生产发布
场景二:接入已有数据库
如果数据库已经在线上稳定运行,或者你接手的是一个老项目,推荐先把数据库结构“拉回”代码中,而不是人工从头重建 schema:
$ pnpm db:pull更稳妥的接入顺序通常是:
- 先备份数据库或在影子库中操作
- 执行
pull生成 schema - 把生成内容转移到
src/database/schema.ts或按领域拆分的 schema 文件中 - 手动整理命名、关系和导出结构
- 之后再切换到正常的
generate + migrate工作流
这种模式适合:
- 公司里已经存在历史数据库
- 先有数据库、后有 Nest 服务
- 要把别的 ORM 或手写 SQL 项目逐步迁到 Drizzle
场景三:快速验证字段设计
如果你还在频繁改字段、索引、默认值,push 是很高效的:
$ pnpm db:push它更适合:
- 本地开发
- 个人项目
- 原型验证
- 演示环境
- 配合 Neon、PlanetScale、Turso 这类云数据库快速试验
但要注意:
- 它不会像 migration 文件那样天然留下可审查的 SQL 历史
- 不适合作为多人协作下唯一的变更依据
- 一旦涉及复杂 DDL、数据回填、手工修复,还是应切到 migration 驱动流程
场景四:团队协作与生产发布
多人协作时,推荐把 schema 变更视为代码变更的一部分:
- 开发者修改
schema.ts - 本地执行
db:generate --name=<meaningful-name> - 提交 schema 和 migration 文件
- CI 执行
db:check - 部署阶段执行
db:migrate
在这类项目里,几个实践非常重要:
- migration 名称要表达业务含义,例如
add-user-status、create-orders-table - 不要让不同开发者在同一分支上随意重写已提交的 migration
- 不要把生产环境 schema 变更只停留在
push - 复杂数据迁移要单独建 custom migration,而不是硬塞进应用启动逻辑
自定义 migration
有些场景不是简单的表结构 diff 能覆盖的,例如:
- 大批量历史数据回填
- 拆表 / 合表
- 重命名后附带数据清洗
- 创建视图、函数、触发器
- 某些 Drizzle Kit 尚未覆盖的 DDL
这时可以创建自定义 migration:
$ pnpm drizzle-kit generate --custom --name backfill-user-slug然后在生成的 SQL 文件中手写:
ALTER TABLE "users" ADD COLUMN "slug" text;
UPDATE "users"
SET "slug" = lower(replace("name", ' ', '-'))
WHERE "slug" IS NULL;
CREATE UNIQUE INDEX "users_slug_unique" ON "users" ("slug");最佳实践是:
- 把“结构变更”和“数据修复”放在同一个 migration 中,确保发布具备原子性
- 对耗时更新分批执行,避免一次锁表太久
- 在生产前先在影子库或 staging 验证执行时间
多环境与多配置文件
当你有开发、测试、生产三套库,或者一个项目里存在多个数据库时,不要共用一个 drizzle.config.ts 硬切环境变量。更可控的做法是拆配置:
// drizzle-dev.config.ts
import { defineConfig } from 'drizzle-kit';
export default defineConfig({
dialect: 'postgresql',
schema: './src/database/**/*.schema.ts',
out: './drizzle/dev',
dbCredentials: {
url: process.env.DEV_DATABASE_URL!,
},
});// drizzle-prod.config.ts
import { defineConfig } from 'drizzle-kit';
export default defineConfig({
dialect: 'postgresql',
schema: './src/database/**/*.schema.ts',
out: './drizzle/prod',
dbCredentials: {
url: process.env.PROD_DATABASE_URL!,
},
migrations: {
table: '__drizzle_migrations',
schema: 'drizzle',
},
});$ drizzle-kit generate --config=drizzle-dev.config.ts
$ drizzle-kit migrate --config=drizzle-prod.config.ts这类拆分适合:
- 多环境数据库
- 多租户控制台 + 主业务库
- 一个 Nest monorepo 下多个服务共享仓库
按领域拆分 schema
小项目把所有表写在一个 schema.ts 里没问题,但项目一旦增长,建议按领域拆:
src
└── database
├── schema
│ ├── users.schema.ts
│ ├── posts.schema.ts
│ ├── billing.schema.ts
│ └── index.ts
├── drizzle.module.ts
└── drizzle.service.ts// schema/index.ts
export * from './users.schema';
export * from './posts.schema';
export * from './billing.schema';// drizzle.service.ts
import * as schema from './schema';
this.db = drizzle({
client: this.pool,
schema,
});同时把 drizzle-kit 的 schema 指向 glob:
schema: './src/database/schema/*.ts',这种结构更适合:
- 中大型 Nest 项目
- 按业务域划分模块
- 希望让 schema 和业务模块天然对应
查询该怎么选:关系查询还是 join
Drizzle 在 Nest 中通常有两套主流写法:
db.query.<table>.findMany/findFirstselect().from(...).leftJoin(...)
推荐的选择原则:
- 面向 API 返回嵌套结构时:优先关系查询
- 面向报表、聚合、复杂筛选时:优先显式 join
- 需要完全掌控 SQL 结构时:优先 query builder
- 只是“用户带文章、文章带作者”这类标准读取:优先关系查询
例如,后台详情页很适合关系查询:
const user = await this.drizzle.db.query.users.findFirst({
where: eq(users.id, id),
with: {
posts: {
columns: {
id: true,
title: true,
},
},
},
});而报表查询通常更适合 join + group:
const rows = await this.drizzle.db
.select({
userId: users.id,
userName: users.name,
postCount: sql<number>`count(${posts.id})`,
})
.from(users)
.leftJoin(posts, eq(posts.userId, users.id))
.groupBy(users.id, users.name);事务最佳实践
事务不是只在“扣款”时才用。下面这些都应优先使用事务:
- 创建主记录后立刻创建子记录
- 更新库存同时写订单
- 改状态同时写审计日志
- 删除主实体同时清理附属数据
事务服务示例:
async createPostAndAudit(dto: CreatePostDto) {
return this.drizzle.db.transaction(async (tx) => {
await tx
.select({ id: users.id })
.from(users)
.where(eq(users.id, dto.userId))
.limit(1);
const [post] = await tx
.insert(posts)
.values({
userId: dto.userId,
title: dto.title,
content: dto.content ?? null,
published: dto.published ? 1 : 0,
})
.returning();
await tx.execute(sql`
insert into audit_logs ("action", "entity_type", "entity_id")
values ('post_created', 'post', ${post.id})
`);
return post;
});
}实践上要注意:
- 事务内部不要做远程 HTTP 调用
- 事务尽量短,避免长时间占锁
- 在 service 层封装事务,不要把事务控制散落在 controller
索引、约束与幂等写入
Drizzle 很适合把“业务约束”前置到 schema 中,而不是全部依赖 service 逻辑:
- 唯一邮箱:唯一索引
- 一对多外键:
references - 必填字段:
notNull - 默认值:
defaultNow、default - 幂等插入:
onConflictDoUpdate
例如,用户邮箱和文章标题组合唯一:
export const posts = pgTable(
'posts',
{
id: serial('id').primaryKey(),
userId: integer('user_id').notNull(),
title: text('title').notNull(),
},
(table) => ({
authorTitleUnique: uniqueIndex('posts_user_title_unique').on(
table.userId,
table.title,
),
}),
);经验上,下面这些索引最值得优先建:
- 外键列
- 高频筛选列
- 高频排序列
- 组合唯一约束列
- 分页主排序字段,例如
created_at
分页、过滤与列表接口最佳实践
在 Nest 中,列表接口最容易退化成“大量 if 拼 SQL”。更推荐的方式是:
- DTO 只负责接收和校验参数
- service 里统一生成 filter 数组
- 最后再通过
and(...filters)组合
这类模式的优点是:
- 条件拼装容易维护
- 新增筛选条件时改动可控
- 与
$count()配合也更自然
同时建议:
- 小中型后台列表优先 offset 分页
- 大表、高并发滚动列表优先游标分页
- 排序字段必须稳定,尽量带唯一辅助排序
Seed 数据与初始化数据
Drizzle 也适合做初始化数据或演示数据管理。常见场景包括:
- 本地开发账号初始化
- 演示环境基础数据
- E2E 测试前置数据
- 字典表、枚举表、权限种子数据
推荐做法有两种:
- 少量固定数据:放在 custom migration 中
- 大量测试数据:单独写 seed 脚本
简单示例:
// scripts/seed.ts
import { drizzle } from 'drizzle-orm/node-postgres';
import { Pool } from 'pg';
import { users } from '../src/database/schema';
async function main() {
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
});
const db = drizzle(pool);
await db.insert(users).values([
{ email: 'admin@example.com', name: 'Admin' },
{ email: 'editor@example.com', name: 'Editor' },
]);
await pool.end();
}
void main();studio 适合做什么
drizzle-kit studio 更像一个轻量数据库工作台,适合:
- 本地调试数据
- 联调时手动确认写入结果
- 快速查看表结构和行数据
- QA 或开发在非生产环境做人工核对
$ pnpm db:studio但最佳实践上:
studio用于开发和测试环境- 不要把它当成生产数据管理后台
- 不要用手工点改代替正式 migration 或后端接口
check 适合放进 CI
如果项目是多人协作,建议在 CI 中加上 drizzle-kit check。它的价值主要在于:
- 尽早暴露迁移冲突
- 检查 schema 与 migration 历史是否一致
- 降低多人并行改表时的合并风险
典型 CI 步骤可以是:
$ pnpm lint
$ pnpm test
$ pnpm db:check读写分离与只读查询
如果你的系统已经有主从复制、读写分离或分析库,可以把 Drizzle 的连接层再封一层,在 Nest 中分别暴露:
primaryDb:写请求、事务、强一致读取replicaDb:列表页、报表、低一致性读取
在 Nest 里通常不建议让业务代码直接感知驱动细节,更推荐单独封装服务:
@Injectable()
export class DatabaseService {
readonly primary: NodePgDatabase<typeof schema>;
readonly replica: NodePgDatabase<typeof schema>;
// 初始化略
}然后在 service 中明确表达意图:
return this.databaseService.replica
.select()
.from(posts)
.orderBy(desc(posts.createdAt))
.limit(20);这种模式适合:
- 内容平台
- 报表后台
- 高读取比例业务
HTTP Proxy / Serverless / Edge 场景
如果你不是运行在传统 Node 进程里,而是:
- Serverless 函数
- Edge Runtime
- 某些不方便直连数据库的网络环境
可以考虑 Drizzle 的 proxy 驱动思路:由 Nest 暴露一个 HTTP 查询代理,再由 Drizzle 客户端通过 HTTP 转发 SQL。
这种模式更适合:
- 平台或网络限制下无法直连数据库
- 需要统一数据库访问网关
- 边缘节点访问中心数据库
但它的成本也很明确:
- 你要自己保证认证、鉴权、限流和 SQL 安全边界
- 整体链路更复杂
- 更适合基础设施明确的团队,而不是一般后台项目的默认方案
批量操作
高频写入、批量初始化、离线任务中,经常会遇到批量操作需求。Drizzle 在 Nest 中常见的批量场景包括:
- 批量插入
- 批量状态更新
- 离线导入任务
- 多条语句组成的批量请求
最简单的是:
await this.drizzle.db.insert(users).values([
{ email: 'a@example.com', name: 'A' },
{ email: 'b@example.com', name: 'B' },
]);注意事项:
- 不要把超大批量一次性塞进单条 SQL
- 导入任务应分批提交,例如每批 500 或 1000
- 需要一致性时用事务包裹批次
测试怎么做更稳
在 Nest + Drizzle 项目里,测试通常分三层:
- 单元测试:mock service,不碰数据库
- 集成测试:连接测试库,真实执行 SQL
- E2E 测试:启动 Nest 应用,走 HTTP 接口
比较推荐的策略是:
- 为测试环境单独准备
DATABASE_URL - 每个测试文件开始前清空核心表或重建 schema
- 对 E2E 场景使用 seed 数据,而不是手工写大量前置请求
如果你用事务回滚隔离测试,也要确保测试框架和数据库连接生命周期匹配,否则很容易出现连接未释放或数据串扰。
常见坑
- 把 DTO 类型直接当数据库插入模型使用,忽略数据库默认值和 nullable 语义
- 忘记在
drizzle()初始化时传schema,导致db.query.*不可用 - 在 controller 中直接堆查询逻辑,后续难以复用和测试
- 只在应用层校验唯一性,不在数据库层建立唯一索引
- 长期用
push却没有任何 migration 历史 - 在生产事务中夹杂外部 HTTP 调用
- 软删除后忘记给所有查询补
deletedAt is null - 批量导入不分批,导致超长事务或锁竞争
- 使用 MySQL 时照搬 PostgreSQL 的
.returning()习惯 - 没有为高频筛选列建索引,导致“类型安全但查询很慢”
最佳实践
- 新项目在本地可用
push提速,但只要进入团队协作或正式环境,就切换到generate + migrate - 把 schema、relations、migration、数据库连接统一收敛在
database基础设施层,不要让业务模块自行创建 Drizzle 实例 - schema 小时可单文件维护,复杂项目务必按领域拆分,并让
drizzle-kit通过 glob 读取多个 schema 文件 - 初始化 Drizzle 时务必传入完整
schema,否则关系查询 API 无法工作 - 读多写少系统可以考虑主从 / 读写分离,但要通过独立基础设施服务暴露给业务层,而不是到处手工挑连接
- API 列表查询统一在 service 层做分页、过滤、排序与 count;controller 只负责接收参数
- 用户邮箱、订单号、幂等键等业务约束必须同时落到数据库唯一索引,而不是只依赖应用层判断
- 任何跨多条 SQL 语句的业务动作,只要要求原子性,就用事务
- 大批量导入、修复、回填优先用批处理或 custom migration,不要在请求接口里直接做重活
- 复杂 DDL、数据修复、初始化字典数据优先通过自定义 migration 或 seed 脚本管理
- 开发环境可用
studio提高排错效率,但不要把它当成正式的数据管理方式 - 在 CI 里加入
drizzle-kit check,尽早发现迁移冲突 - 如果你有大量输入模型重复定义,可以引入
drizzle-orm/zod或 Zod 管道体系减少 DTO 重复
总结
Drizzle 很适合 Nest 中“明确 schema、明确 SQL、明确边界”的开发方式。对于大多数业务系统,你至少应该具备下面这套能力:
- 用
schema.ts统一声明表、索引和关系 - 用
drizzle-kit管理迁移 - 用独立
DrizzleModule统一管理连接 - 在 service 中完成 CRUD、分页、过滤、关系查询和事务
- 在需要时通过
drizzle-orm/zod复用 schema 做输入校验 - 根据项目阶段选择正确的
push / generate / migrate / pull / check / studio工作流 - 在多环境、多人协作、已有数据库、读写分离、seed 与数据修复等场景下,用更贴近生产的方式落地 Drizzle
做到这些,基本就能覆盖绝大多数中后台项目中 90% 以上的数据库访问场景。