Skip to content

Drizzle ORM

Drizzle ORM 是一个面向 TypeScript 的轻量级 ORM / SQL 查询构建器。它的核心思路不是隐藏 SQL,而是在尽可能贴近 SQL 的前提下,提供强类型推导、关系查询、迁移管理与多数据库驱动支持。

在 Nest 中,Drizzle 很适合以下场景:

  • 你希望保留接近原生 SQL 的表达能力,而不是完全依赖 Active Record / Repository 抽象。
  • 你希望 schema、查询和返回类型都由 TypeScript 推导出来。
  • 你需要 PostgreSQL、MySQL 或 SQLite 这类传统关系型数据库,并希望保留良好的可测试性与模块化结构。

提示

drizzle-ormdrizzle-kit 都是第三方库,并非由 Nest 核心团队维护。如果你遇到库本身的问题,应在其官方仓库中反馈。

安装

下面以 PostgreSQL 为例:

bash
$ 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 模块命名:

plaintext
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 与事务,而不是规定你的业务模块一定要这样命名:

typescript
// 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-coresqlite-core,表定义思路保持不变。

配置 Drizzle Kit

drizzle-kit 负责 schema diff、迁移生成、迁移执行以及从现有数据库反向生成 schema。

typescript
// 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 里加入这些脚本:

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 实例封装在独立模块里,并在应用关闭时释放连接池。

typescript
// 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();
  }
}
typescript
// 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 只是用于后文演示增删改查、关联读取和事务:

typescript
// 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
  • 关系查询
  • 事务

它们应被理解为“可迁移的写法模板”,而不是“必须照抄的模块结构”。

在真实项目中,更推荐你把这些查询模式迁移到自己的领域模块里,例如:

  • AccountsService
  • OrdersService
  • BillingService
  • InventoryService

而不是真的维护一个 UsersModule / PostsModule demo。

Demo:用户模块

下面通过一个完整的 UsersModule 演示 Drizzle 在 Nest 中的常规 CRUD 写法。

typescript
// 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:

typescript
// 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;
}
typescript
// 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;
}
typescript
// 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 写法模式

typescript
// 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():按唯一键执行 upsert
  • findUserWithPosts():关系查询

提示

在 PostgreSQL 和 SQLite 中,insert / update / delete 可以直接使用 .returning()。如果你使用 MySQL,则通常需要改用 $returningId(),或者执行额外一次查询来拿回写入后的记录。

这些模式如何迁移到真实业务模块

上面的 demo 可以直接映射到真实业务中:

  • create():创建订单、商品、分类、租户、标签
  • createMany():批量导入用户、批量生成任务、批量插入字典数据
  • findAll():后台列表页、管理台筛选页、导出预览页
  • findOne():详情页、编辑页、审批页
  • update():局部修改、状态更新、补充字段
  • remove():物理删除或你自己的软删除封装
  • upsertByEmail():幂等写入、第三方同步、外部 webhook 落库
  • findUserWithPosts():带子资源的详情页、聚合读取、嵌套接口返回

如果你在自己的项目里写的是 OrdersService,完全可以照搬这种结构:

typescript
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 控制器

typescript
// 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:文章模块

当项目里存在明确的主从关系时,可以像这里的 UsersModulePostsModule 一样拆开演示。但在真实项目中,你应该按自己的业务边界拆分,而不是按教程里的 demo 资源名拆分。

typescript
// 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

typescript
// 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;
}
typescript
// 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;
}
typescript
// 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 展示的是双模块场景下最常见的写法:列表筛选、关联作者校验、单条查询、更新、删除、按作者聚合查询。

typescript
// 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 控制器

typescript
// 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 理解成下面几层职责:

plaintext
src
├── database
│   ├── drizzle.module.ts
│   ├── drizzle.service.ts
│   ├── schema
│   └── migrations
├── modules
│   ├── accounts
│   ├── orders
│   ├── billing
│   └── inventory
└── shared

每个业务模块只关心:

  • 它依赖哪些表
  • 它暴露哪些 CRUD / query 方法
  • 它是否需要事务
  • 它是否需要读写分离、分页、聚合或关系查询

而不是关心“是不是照着 demo 的 users/posts 来建目录”。

事务

当一个业务操作需要跨多条语句保持原子性时,应使用事务。比如创建用户的同时写入一篇欢迎文章:

typescript
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 结果。

typescript
const usersWithPosts = await this.drizzle.db.query.users.findMany({
  with: {
    posts: true,
  },
});

如果你更偏向 SQL 风格,也可以直接写 join:

typescript
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

bash
$ npm i zod
typescript
// 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() 使用的是物理删除,适合日志型、临时型或关系清晰的业务表。对于用户、订单、账单等高价值数据,更推荐软删除:

typescript
// 在 schema 中新增 deletedAt
deletedAt: timestamp('deleted_at', { withTimezone: true }),
typescript
await this.drizzle.db
  .update(users)
  .set({
    deletedAt: sql`now()`,
    updatedAt: sql`now()`,
  })
  .where(eq(users.id, id));

后续所有查询都加上 isNull(users.deletedAt) 条件即可。

已有数据库项目如何接入

如果你的数据库已经存在,而不是从代码优先开始,可以使用:

bash
$ npx drizzle-kit pull

它会读取现有数据库结构,并生成 Drizzle schema。对于老项目迁移,这通常比手写 schema 更稳妥。

drizzle-kit 命令如何选

很多团队在刚接触 Drizzle 时,最容易混淆的不是查询写法,而是到底应该用 pushgeneratemigratepullcheckstudio 中的哪一个。可以按下面的场景来选:

  • 原型开发、个人项目、本地快速试错:优先 push
  • 正式项目、多人协作、需要审计 SQL 变更:优先 generate + migrate
  • 接手已有数据库、先让代码贴近现有库结构:优先 pull
  • 发布前检查迁移历史或 CI 校验:增加 check
  • 本地可视化查看和手工排查数据:使用 studio
  • 需要补充数据修复、回填、DDL 之外的特殊 SQL:使用自定义 migration

你可以把脚本进一步细化成:

json
{
  "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 项目一起从零启动,最推荐的流程是:

  1. 先写 schema.ts
  2. 本地开发阶段可以用 db:push 快速验证
  3. 一旦进入团队协作或测试环境,切到 db:generate + db:migrate
  4. 所有 schema 变更都通过 PR 审查 migration SQL

典型流程如下:

bash
$ pnpm db:generate --name add-user-status
$ pnpm db:migrate

这种方式的优点是:

  • schema 仍由 TypeScript 主导
  • 最终落库动作通过 SQL migration 执行
  • 迁移历史可审查、可回放、可追踪
  • 更适合灰度、回归测试和生产发布

场景二:接入已有数据库

如果数据库已经在线上稳定运行,或者你接手的是一个老项目,推荐先把数据库结构“拉回”代码中,而不是人工从头重建 schema:

bash
$ pnpm db:pull

更稳妥的接入顺序通常是:

  1. 先备份数据库或在影子库中操作
  2. 执行 pull 生成 schema
  3. 把生成内容转移到 src/database/schema.ts 或按领域拆分的 schema 文件中
  4. 手动整理命名、关系和导出结构
  5. 之后再切换到正常的 generate + migrate 工作流

这种模式适合:

  • 公司里已经存在历史数据库
  • 先有数据库、后有 Nest 服务
  • 要把别的 ORM 或手写 SQL 项目逐步迁到 Drizzle

场景三:快速验证字段设计

如果你还在频繁改字段、索引、默认值,push 是很高效的:

bash
$ pnpm db:push

它更适合:

  • 本地开发
  • 个人项目
  • 原型验证
  • 演示环境
  • 配合 Neon、PlanetScale、Turso 这类云数据库快速试验

但要注意:

  • 它不会像 migration 文件那样天然留下可审查的 SQL 历史
  • 不适合作为多人协作下唯一的变更依据
  • 一旦涉及复杂 DDL、数据回填、手工修复,还是应切到 migration 驱动流程

场景四:团队协作与生产发布

多人协作时,推荐把 schema 变更视为代码变更的一部分:

  1. 开发者修改 schema.ts
  2. 本地执行 db:generate --name=<meaningful-name>
  3. 提交 schema 和 migration 文件
  4. CI 执行 db:check
  5. 部署阶段执行 db:migrate

在这类项目里,几个实践非常重要:

  • migration 名称要表达业务含义,例如 add-user-statuscreate-orders-table
  • 不要让不同开发者在同一分支上随意重写已提交的 migration
  • 不要把生产环境 schema 变更只停留在 push
  • 复杂数据迁移要单独建 custom migration,而不是硬塞进应用启动逻辑

自定义 migration

有些场景不是简单的表结构 diff 能覆盖的,例如:

  • 大批量历史数据回填
  • 拆表 / 合表
  • 重命名后附带数据清洗
  • 创建视图、函数、触发器
  • 某些 Drizzle Kit 尚未覆盖的 DDL

这时可以创建自定义 migration:

bash
$ pnpm drizzle-kit generate --custom --name backfill-user-slug

然后在生成的 SQL 文件中手写:

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 硬切环境变量。更可控的做法是拆配置:

typescript
// 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!,
  },
});
typescript
// 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',
  },
});
bash
$ drizzle-kit generate --config=drizzle-dev.config.ts
$ drizzle-kit migrate --config=drizzle-prod.config.ts

这类拆分适合:

  • 多环境数据库
  • 多租户控制台 + 主业务库
  • 一个 Nest monorepo 下多个服务共享仓库

按领域拆分 schema

小项目把所有表写在一个 schema.ts 里没问题,但项目一旦增长,建议按领域拆:

plaintext
src
└── database
    ├── schema
    │   ├── users.schema.ts
    │   ├── posts.schema.ts
    │   ├── billing.schema.ts
    │   └── index.ts
    ├── drizzle.module.ts
    └── drizzle.service.ts
typescript
// schema/index.ts
export * from './users.schema';
export * from './posts.schema';
export * from './billing.schema';
typescript
// drizzle.service.ts
import * as schema from './schema';

this.db = drizzle({
  client: this.pool,
  schema,
});

同时把 drizzle-kitschema 指向 glob:

typescript
schema: './src/database/schema/*.ts',

这种结构更适合:

  • 中大型 Nest 项目
  • 按业务域划分模块
  • 希望让 schema 和业务模块天然对应

查询该怎么选:关系查询还是 join

Drizzle 在 Nest 中通常有两套主流写法:

  • db.query.<table>.findMany/findFirst
  • select().from(...).leftJoin(...)

推荐的选择原则:

  • 面向 API 返回嵌套结构时:优先关系查询
  • 面向报表、聚合、复杂筛选时:优先显式 join
  • 需要完全掌控 SQL 结构时:优先 query builder
  • 只是“用户带文章、文章带作者”这类标准读取:优先关系查询

例如,后台详情页很适合关系查询:

typescript
const user = await this.drizzle.db.query.users.findFirst({
  where: eq(users.id, id),
  with: {
    posts: {
      columns: {
        id: true,
        title: true,
      },
    },
  },
});

而报表查询通常更适合 join + group:

typescript
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);

事务最佳实践

事务不是只在“扣款”时才用。下面这些都应优先使用事务:

  • 创建主记录后立刻创建子记录
  • 更新库存同时写订单
  • 改状态同时写审计日志
  • 删除主实体同时清理附属数据

事务服务示例:

typescript
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
  • 默认值:defaultNowdefault
  • 幂等插入:onConflictDoUpdate

例如,用户邮箱和文章标题组合唯一:

typescript
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”。更推荐的方式是:

  1. DTO 只负责接收和校验参数
  2. service 里统一生成 filter 数组
  3. 最后再通过 and(...filters) 组合

这类模式的优点是:

  • 条件拼装容易维护
  • 新增筛选条件时改动可控
  • $count() 配合也更自然

同时建议:

  • 小中型后台列表优先 offset 分页
  • 大表、高并发滚动列表优先游标分页
  • 排序字段必须稳定,尽量带唯一辅助排序

Seed 数据与初始化数据

Drizzle 也适合做初始化数据或演示数据管理。常见场景包括:

  • 本地开发账号初始化
  • 演示环境基础数据
  • E2E 测试前置数据
  • 字典表、枚举表、权限种子数据

推荐做法有两种:

  • 少量固定数据:放在 custom migration 中
  • 大量测试数据:单独写 seed 脚本

简单示例:

typescript
// 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 或开发在非生产环境做人工核对
bash
$ pnpm db:studio

但最佳实践上:

  • studio 用于开发和测试环境
  • 不要把它当成生产数据管理后台
  • 不要用手工点改代替正式 migration 或后端接口

check 适合放进 CI

如果项目是多人协作,建议在 CI 中加上 drizzle-kit check。它的价值主要在于:

  • 尽早暴露迁移冲突
  • 检查 schema 与 migration 历史是否一致
  • 降低多人并行改表时的合并风险

典型 CI 步骤可以是:

bash
$ pnpm lint
$ pnpm test
$ pnpm db:check

读写分离与只读查询

如果你的系统已经有主从复制、读写分离或分析库,可以把 Drizzle 的连接层再封一层,在 Nest 中分别暴露:

  • primaryDb:写请求、事务、强一致读取
  • replicaDb:列表页、报表、低一致性读取

在 Nest 里通常不建议让业务代码直接感知驱动细节,更推荐单独封装服务:

typescript
@Injectable()
export class DatabaseService {
  readonly primary: NodePgDatabase<typeof schema>;
  readonly replica: NodePgDatabase<typeof schema>;

  // 初始化略
}

然后在 service 中明确表达意图:

typescript
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 中常见的批量场景包括:

  • 批量插入
  • 批量状态更新
  • 离线导入任务
  • 多条语句组成的批量请求

最简单的是:

typescript
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% 以上的数据库访问场景。

基于 NestJS 官方文档翻译