#Drizzle ORM 集成
本示例展示如何将 Drizzle ORM 集成到 VextJS 项目中,通过编写一个数据库插件实现类型安全的数据库操作。
#为什么选择 Drizzle ORM?
| 特性 | 说明 |
|---|---|
| 类型安全 | Schema 即类型,查询结果自动推断 TypeScript 类型 |
| 轻量 | 无运行时代码生成,零抽象开销 |
| SQL-like | API 贴近 SQL 语法,学习成本低 |
| 多数据库 | 支持 PostgreSQL、MySQL、SQLite |
| 迁移工具 | 内置 drizzle-kit 进行 schema 迁移 |
| 性能 | 无额外查询层,直接编译为 SQL |
#项目结构
drizzle-orm/
├── src/
│ ├── config/
│ │ ├── default.ts
│ │ └── production.ts
│ ├── db/
│ │ ├── schema.ts ← Drizzle schema 定义
│ │ ├── index.ts ← 数据库连接工厂
│ │ └── migrate.ts ← 迁移脚本
│ ├── plugins/
│ │ └── database.ts ← 数据库插件
│ ├── routes/
│ │ ├── index.ts
│ │ └── users.ts
│ ├── services/
│ │ └── user.ts
│ └── index.ts
├── drizzle/ ← 迁移文件(drizzle-kit 生成)
├── drizzle.config.ts ← drizzle-kit 配置
├── package.json
└── tsconfig.json#1. 安装依赖
本示例使用 SQLite(通过 better-sqlite3)作为演示数据库。生产环境中可替换为 PostgreSQL 或 MySQL。
npx vextjs create drizzle-orm-demo
cd drizzle-orm-demo
# Drizzle ORM + SQLite 驱动
pnpm add drizzle-orm better-sqlite3
# 开发依赖
pnpm add -D drizzle-kit @types/better-sqlite3Tip
其他数据库驱动:
- PostgreSQL:
pnpm add drizzle-orm postgres或pnpm add drizzle-orm @neondatabase/serverless - MySQL:
pnpm add drizzle-orm mysql2
更换驱动后只需修改 src/db/index.ts 中的连接逻辑和 src/db/schema.ts 中的表定义导入路径,业务代码几乎无需改动。
#2. 数据库 Schema
使用 Drizzle 的 TypeScript-first schema 定义表结构。Schema 既是数据库表定义,也是 TypeScript 类型的来源。
// src/db/schema.ts
import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core';
import { sql } from 'drizzle-orm';
// ── 用户表 ──────────────────────────────────────────────────
export const users = sqliteTable('users', {
id: integer('id').primaryKey({ autoIncrement: true }),
name: text('name').notNull(),
email: text('email').notNull().unique(),
age: integer('age'),
role: text('role', { enum: ['admin', 'user', 'editor'] })
.notNull()
.default('user'),
bio: text('bio'),
avatarUrl: text('avatar_url'),
createdAt: text('created_at')
.notNull()
.default(sql`(datetime('now'))`),
updatedAt: text('updated_at')
.notNull()
.default(sql`(datetime('now'))`),
});
// ── 文章表(演示关联查询)──────────────────────────────────
export const posts = sqliteTable('posts', {
id: integer('id').primaryKey({ autoIncrement: true }),
title: text('title').notNull(),
content: text('content').notNull(),
authorId: integer('author_id')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
published: integer('published', { mode: 'boolean' })
.notNull()
.default(false),
createdAt: text('created_at')
.notNull()
.default(sql`(datetime('now'))`),
updatedAt: text('updated_at')
.notNull()
.default(sql`(datetime('now'))`),
});
// ── 导出推断类型 ────────────────────────────────────────────
/** 用户完整类型(查询结果) */
export type User = typeof users.$inferSelect;
/** 用户插入类型(创建时使用) */
export type NewUser = typeof users.$inferInsert;
/** 文章完整类型 */
export type Post = typeof posts.$inferSelect;
/** 文章插入类型 */
export type NewPost = typeof posts.$inferInsert;Tip
Drizzle 的 $inferSelect 和 $inferInsert 类型工具非常强大:
$inferSelect— 从表定义推断查询结果类型(包含所有字段,id、createdAt等为必填)$inferInsert— 从表定义推断插入类型(有默认值的字段变为可选,如role、createdAt)
这些类型可以直接用于服务层的参数和返回值声明,无需手动维护接口定义。
#3. 数据库连接工厂
// src/db/index.ts
import { drizzle } from 'drizzle-orm/better-sqlite3';
import Database from 'better-sqlite3';
import * as schema from './schema.js';
export interface DatabaseConfig {
/** SQLite 数据库文件路径 */
url: string;
/** 是否开启 WAL 模式(Write-Ahead Logging,提升并发性能) */
wal?: boolean;
/** 是否在连接时自动创建表(开发环境用) */
autoCreate?: boolean;
}
/**
* 创建 Drizzle ORM 数据库实例
*
* @param config 数据库配置
* @returns { db, client }
* - db: Drizzle ORM 实例(用于查询)
* - client: better-sqlite3 原始客户端(用于关闭连接)
*/
export function createDatabase(config: DatabaseConfig) {
const client = new Database(config.url);
// WAL 模式:提升并发读写性能
if (config.wal !== false) {
client.pragma('journal_mode = WAL');
}
// 启用外键约束
client.pragma('foreign_keys = ON');
const db = drizzle(client, { schema });
// 开发环境自动创建表
if (config.autoCreate) {
client.exec(`
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
email TEXT NOT NULL UNIQUE,
age INTEGER,
role TEXT NOT NULL DEFAULT 'user',
bio TEXT,
avatar_url TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS posts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
content TEXT NOT NULL,
author_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
published INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
`);
}
return { db, client };
}
/** Drizzle DB 实例类型 */
export type DrizzleDB = ReturnType<typeof createDatabase>['db'];Warning
生产环境中不要使用 autoCreate: true。应该使用 drizzle-kit 迁移工具管理数据库结构变更。autoCreate 仅用于快速开发和演示。
#4. 数据库迁移配置
// drizzle.config.ts
import { defineConfig } from 'drizzle-kit';
export default defineConfig({
schema: './src/db/schema.ts',
out: './drizzle',
dialect: 'sqlite',
dbCredentials: {
url: process.env.DATABASE_URL ?? './data/app.db',
},
});迁移命令:
# 生成迁移文件
pnpm drizzle-kit generate
# 执行迁移
pnpm drizzle-kit migrate
# 打开 Drizzle Studio(可视化数据库管理)
pnpm drizzle-kit studio#5. 数据库插件
// src/plugins/database.ts
import { definePlugin } from 'vextjs';
import { createDatabase } from '../db/index.js';
import type { DrizzleDB } from '../db/index.js';
import { users, posts } from '../db/schema.js';
export default definePlugin({
name: 'database',
async setup(app) {
const dbConfig = (app.config as any).database ?? {
url: './data/app.db',
wal: true,
autoCreate: true,
};
app.logger.info({ url: dbConfig.url }, '正在连接数据库...');
const { db, client } = createDatabase(dbConfig);
// 挂载到 app
app.extend('db', db);
// 就绪钩子:验证连接 + 填充种子数据
app.onReady(async () => {
try {
// 验证连接
client.pragma('quick_check');
app.logger.info('数据库连接验证成功');
// 检查是否需要种子数据
const userCount = db.select().from(users).all();
if (userCount.length === 0) {
app.logger.info('数据库为空,正在填充种子数据...');
await seedDatabase(db);
app.logger.info('种子数据填充完成');
}
} catch (err) {
app.logger.error({ error: err }, '数据库初始化失败');
throw err;
}
});
// 关闭钩子:断开连接
app.onClose(() => {
client.close();
app.logger.info('数据库连接已关闭');
});
},
});
/**
* 填充种子数据(仅在数据库为空时执行)
*/
async function seedDatabase(db: DrizzleDB): Promise<void> {
// 插入用户
const insertedUsers = db
.insert(users)
.values([
{
name: 'Alice',
email: 'alice@example.com',
age: 28,
role: 'admin',
bio: '全栈开发者,热爱开源',
},
{
name: 'Bob',
email: 'bob@example.com',
age: 32,
role: 'user',
bio: '前端工程师',
},
{
name: 'Charlie',
email: 'charlie@example.com',
age: 25,
role: 'editor',
bio: '技术博客作者',
},
])
.returning()
.all();
// 插入文章
db.insert(posts)
.values([
{
title: 'VextJS 入门指南',
content: '本文介绍如何使用 VextJS 构建高性能 RESTful API...',
authorId: insertedUsers[0].id,
published: true,
},
{
title: 'Drizzle ORM 最佳实践',
content: '探索 Drizzle ORM 的高级查询技巧...',
authorId: insertedUsers[0].id,
published: true,
},
{
title: 'TypeScript 类型体操',
content: '深入理解 TypeScript 的高级类型系统...',
authorId: insertedUsers[2].id,
published: false,
},
])
.run();
}为 app.db 添加类型声明:
// types/vext.d.ts
import type { DrizzleDB } from '../src/db/index.js';
declare module 'vextjs' {
interface VextApp {
db: DrizzleDB;
}
interface VextConfig {
database?: {
url: string;
wal?: boolean;
autoCreate?: boolean;
};
}
interface VextRequest {
user?: {
id: string;
role: string;
};
}
}#6. 配置
// src/config/default.ts
export default {
port: 3000,
adapter: 'native',
logger: {
level: 'debug',
pretty: true,
},
cors: {
enabled: true,
origins: ['*'],
},
response: {
wrap: true,
hideInternalErrors: false,
},
openapi: {
enabled: true,
title: 'Drizzle ORM 示例',
version: '1.0.0',
description: '使用 Drizzle ORM 集成的 VextJS RESTful API',
tags: [
{ name: '基础', description: '基础接口' },
{ name: '用户', description: '用户管理接口' },
{ name: '文章', description: '文章管理接口' },
],
securitySchemes: {
bearerAuth: {
type: 'http',
scheme: 'bearer',
bearerFormat: 'JWT',
},
},
guardSecurityMap: {
auth: 'bearerAuth',
},
},
middlewares: [
{ name: 'auth' },
],
// 数据库配置
database: {
url: './data/app.db',
wal: true,
autoCreate: true,
},
};// src/config/production.ts
export default {
logger: {
level: 'info',
pretty: false,
},
response: {
hideInternalErrors: true,
},
openapi: {
enabled: false,
},
database: {
url: process.env.DATABASE_URL ?? './data/production.db',
autoCreate: false, // 生产环境使用迁移
},
};#7. 服务层
// src/services/user.ts
import type { VextApp, VextLogger } from 'vextjs';
import { users, posts } from '../db/schema.js';
import type { User, NewUser } from '../db/schema.js';
import { eq, like, or, sql, desc, asc, count } from 'drizzle-orm';
export default class UserService {
private logger: VextLogger;
constructor(private app: VextApp) {
this.logger = app.logger.child({ service: 'UserService' });
}
/**
* 分页查询用户列表
*
* 支持关键词搜索(按 name 或 email 模糊匹配)和排序。
*/
async findAll(options: {
page: number;
limit: number;
keyword?: string;
sortBy?: 'name' | 'createdAt';
sortOrder?: 'asc' | 'desc';
}): Promise<{
items: User[];
total: number;
page: number;
limit: number;
totalPages: number;
}> {
this.logger.debug(options, '查询用户列表');
const db = this.app.db;
const offset = (options.page - 1) * options.limit;
// 构建 WHERE 条件
const whereCondition = options.keyword
? or(
like(users.name, `%${options.keyword}%`),
like(users.email, `%${options.keyword}%`),
)
: undefined;
// 构建排序
const orderBy =
options.sortBy === 'name'
? options.sortOrder === 'desc'
? desc(users.name)
: asc(users.name)
: options.sortOrder === 'asc'
? asc(users.createdAt)
: desc(users.createdAt);
// 查询数据
const items = db
.select()
.from(users)
.where(whereCondition)
.orderBy(orderBy)
.limit(options.limit)
.offset(offset)
.all();
// 查询总数
const [{ total }] = db
.select({ total: count() })
.from(users)
.where(whereCondition)
.all();
return {
items,
total,
page: options.page,
limit: options.limit,
totalPages: Math.ceil(total / options.limit),
};
}
/**
* 根据 ID 查询用户(含文章列表)
*/
async findById(id: number): Promise<(User & { posts: any[] }) | null> {
this.logger.debug({ userId: id }, '查询用户详情');
const db = this.app.db;
const user = db.select().from(users).where(eq(users.id, id)).get();
if (!user) return null;
// 查询该用户的文章
const userPosts = db
.select()
.from(posts)
.where(eq(posts.authorId, id))
.orderBy(desc(posts.createdAt))
.all();
return {
...user,
posts: userPosts,
};
}
/**
* 创建用户
*/
async create(data: {
name: string;
email: string;
age?: number;
role?: 'admin' | 'user' | 'editor';
bio?: string;
avatarUrl?: string;
}): Promise<User> {
this.logger.info({ email: data.email }, '创建用户');
const db = this.app.db;
// 检查邮箱唯一性
const existing = db
.select({ id: users.id })
.from(users)
.where(eq(users.email, data.email))
.get();
if (existing) {
this.app.throw(409, '邮箱已注册', 10001);
}
// 插入并返回完整记录
const user = db
.insert(users)
.values({
name: data.name,
email: data.email,
age: data.age,
role: data.role ?? 'user',
bio: data.bio,
avatarUrl: data.avatarUrl,
})
.returning()
.get();
this.logger.info({ userId: user.id, email: user.email }, '用户创建成功');
return user;
}
/**
* 更新用户
*/
async update(
id: number,
data: {
name?: string;
email?: string;
age?: number;
bio?: string;
avatarUrl?: string;
},
): Promise<User> {
this.logger.info({ userId: id }, '更新用户');
const db = this.app.db;
// 检查用户是否存在
const existing = db.select().from(users).where(eq(users.id, id)).get();
if (!existing) {
this.app.throw(404, '用户不存在');
}
// 如果更新邮箱,检查唯一性
if (data.email && data.email !== existing.email) {
const emailTaken = db
.select({ id: users.id })
.from(users)
.where(eq(users.email, data.email))
.get();
if (emailTaken) {
this.app.throw(409, '邮箱已被其他用户使用', 10002);
}
}
// 更新记录
const updated = db
.update(users)
.set({
...data,
updatedAt: sql`datetime('now')`,
})
.where(eq(users.id, id))
.returning()
.get();
this.logger.info({ userId: id }, '用户更新成功');
return updated;
}
/**
* 删除用户
*
* 由于设置了 ON DELETE CASCADE,关联的文章会自动删除。
*/
async delete(id: number): Promise<void> {
this.logger.info({ userId: id }, '删除用户');
const db = this.app.db;
const existing = db
.select({ id: users.id })
.from(users)
.where(eq(users.id, id))
.get();
if (!existing) {
this.app.throw(404, '用户不存在');
}
db.delete(users).where(eq(users.id, id)).run();
this.logger.info({ userId: id }, '用户删除成功');
}
/**
* 统计用户数量
*/
async count(): Promise<number> {
const [{ total }] = this.app.db
.select({ total: count() })
.from(users)
.all();
return total;
}
}#8. 路由
#根路由
// src/routes/index.ts
import { defineRoutes } from 'vextjs';
export default defineRoutes((app) => {
app.get('/', {
docs: {
summary: '健康检查',
tags: ['基础'],
},
}, async (_req, res) => {
const userCount = await app.services.user.count();
res.json({
status: 'ok',
uptime: Math.floor(process.uptime()),
database: 'connected',
users: userCount,
timestamp: new Date().toISOString(),
});
});
});#用户路由
// src/routes/users.ts
import { defineRoutes } from 'vextjs';
export default defineRoutes((app) => {
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// GET /users/list — 分页查询用户列表
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
app.get('/list', {
validate: {
query: {
page: 'number:1-',
limit: 'number:1-100',
keyword: 'string?',
},
},
docs: {
summary: '用户列表',
description: '分页查询用户列表。支持按姓名或邮箱关键词搜索。',
tags: ['用户'],
responses: {
200: {
description: '查询成功',
example: {
items: [
{
id: 1,
name: 'Alice',
email: 'alice@example.com',
age: 28,
role: 'admin',
bio: '全栈开发者',
createdAt: '2026-03-05 00:00:00',
},
],
total: 3,
page: 1,
limit: 10,
totalPages: 1,
},
},
},
},
}, async (req, res) => {
const { page, limit, keyword } = req.valid('query');
const result = await app.services.user.findAll({ page, limit, keyword });
res.json(result);
});
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// GET /users/:id — 查询用户详情(含文章)
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
app.get('/:id', {
validate: {
param: { id: 'number:1-' },
},
docs: {
summary: '获取用户详情',
description: '查询用户详情,包含该用户发布的所有文章。',
tags: ['用户'],
responses: {
200: { description: '查询成功' },
404: { description: '用户不存在' },
},
},
}, async (req, res) => {
const { id } = req.valid('param');
const user = await app.services.user.findById(id);
if (!user) {
app.throw(404, '用户不存在');
}
res.json(user);
});
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// POST /users — 创建用户(需认证)
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
app.post('/', {
validate: {
body: {
name: 'string:1-50',
email: 'email',
age: 'number:0-200?',
role: 'enum:admin,user,editor?',
},
},
middlewares: ['auth'],
docs: {
summary: '创建用户',
description: '创建新用户。需要 Bearer Token 认证。邮箱必须唯一。',
tags: ['用户'],
responses: {
201: { description: '创建成功' },
400: { description: '参数校验失败' },
401: { description: '未认证' },
409: { description: '邮箱已注册' },
},
},
}, async (req, res) => {
const body = req.valid('body');
const user = await app.services.user.create(body);
res.json(user, 201);
});
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// PUT /users/:id — 更新用户(需认证)
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
app.put('/:id', {
validate: {
param: { id: 'number:1-' },
body: {
name: 'string:1-50?',
email: 'email?',
age: 'number:0-200?',
},
},
middlewares: ['auth'],
docs: {
summary: '更新用户',
description: '更新用户信息。只需传入需要更新的字段。',
tags: ['用户'],
responses: {
200: { description: '更新成功' },
400: { description: '参数校验失败' },
401: { description: '未认证' },
404: { description: '用户不存在' },
409: { description: '邮箱已被占用' },
},
},
}, async (req, res) => {
const { id } = req.valid('param');
const body = req.valid('body');
const user = await app.services.user.update(id, body);
res.json(user);
});
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// DELETE /users/:id — 删除用户(需认证)
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
app.delete('/:id', {
validate: {
param: { id: 'number:1-' },
},
middlewares: ['auth'],
docs: {
summary: '删除用户',
description: '删除用户及其所有文章(CASCADE)。不可逆操作。',
tags: ['用户'],
responses: {
204: { description: '删除成功' },
401: { description: '未认证' },
404: { description: '用户不存在' },
},
},
}, async (req, res) => {
const { id } = req.valid('param');
await app.services.user.delete(id);
res.status(204).json(null);
});
});#9. 入口文件
// src/index.ts
import { bootstrap } from 'vextjs';
bootstrap().catch((err) => {
console.error('启动失败:', err);
process.exit(1);
});#10. 运行与验证
# 创建数据目录
mkdir -p data
# 启动开发服务器
pnpm dev#接口验证
# 健康检查
curl http://localhost:3000/
# → {"code":0,"data":{"status":"ok","database":"connected","users":3,...},"requestId":"..."}
# 查询用户列表
curl "http://localhost:3000/users/list?page=1&limit=10"
# → {"code":0,"data":{"items":[...],"total":3,"page":1,"limit":10,"totalPages":1},"requestId":"..."}
# 搜索用户
curl "http://localhost:3000/users/list?page=1&limit=10&keyword=alice"
# → 只返回匹配 "alice" 的用户
# 查询用户详情(含文章)
curl http://localhost:3000/users/1
# → {"code":0,"data":{"id":1,"name":"Alice",...,"posts":[...]},"requestId":"..."}
# 创建用户
curl -X POST http://localhost:3000/users \
-H "Content-Type: application/json" \
-H "Authorization: Bearer user-1-admin" \
-d '{"name":"Diana","email":"diana@example.com","age":26}'
# → 201 {"code":0,"data":{"id":4,"name":"Diana","email":"diana@example.com",...},"requestId":"..."}
# 更新用户
curl -X PUT http://localhost:3000/users/4 \
-H "Content-Type: application/json" \
-H "Authorization: Bearer user-1-admin" \
-d '{"name":"Diana Updated","age":27}'
# → {"code":0,"data":{"id":4,"name":"Diana Updated","age":27,...},"requestId":"..."}
# 删除用户
curl -X DELETE http://localhost:3000/users/4 \
-H "Authorization: Bearer user-1-admin"
# → 204 No Content
# 邮箱唯一性校验
curl -X POST http://localhost:3000/users \
-H "Content-Type: application/json" \
-H "Authorization: Bearer user-1-admin" \
-d '{"name":"Test","email":"alice@example.com"}'
# → 409 {"code":10001,"message":"邮箱已注册","requestId":"..."}#11. 测试
// test/users.test.ts
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { createTestApp } from 'vextjs/testing';
import type { TestApp } from 'vextjs';
describe('用户 CRUD (Drizzle)', () => {
let testApp: TestApp;
const AUTH = 'Bearer user-1-admin';
beforeEach(async () => {
testApp = await createTestApp({
plugins: true, // 加载数据库插件
config: {
database: {
url: ':memory:', // 使用内存数据库,测试间隔离
autoCreate: true,
},
},
});
});
afterEach(async () => {
await testApp?.close();
});
it('GET /users/list 应返回种子数据', async () => {
const res = await testApp.request
.get('/users/list')
.query({ page: 1, limit: 10 });
expect(res.status).toBe(200);
expect(res.body.data.total).toBe(3); // 种子数据 3 个用户
expect(res.body.data.items[0]).toHaveProperty('id');
expect(res.body.data.items[0]).toHaveProperty('name');
expect(res.body.data.items[0]).toHaveProperty('email');
});
it('GET /users/:id 应包含关联文章', async () => {
const res = await testApp.request.get('/users/1');
expect(res.status).toBe(200);
expect(res.body.data.name).toBe('Alice');
expect(Array.isArray(res.body.data.posts)).toBe(true);
expect(res.body.data.posts.length).toBeGreaterThan(0);
});
it('POST /users 应创建用户并返回自增 ID', async () => {
const res = await testApp.request
.post('/users')
.set('Authorization', AUTH)
.send({
name: 'Diana',
email: 'diana@example.com',
age: 26,
});
expect(res.status).toBe(201);
expect(res.body.data.id).toBe(4); // 种子数据占了 1-3
expect(res.body.data.role).toBe('user'); // 默认值
expect(res.body.data.createdAt).toBeDefined();
});
it('DELETE /users/:id 应级联删除文章', async () => {
// Alice (id=1) 有 2 篇文章
const deleteRes = await testApp.request
.delete('/users/1')
.set('Authorization', AUTH);
expect(deleteRes.status).toBe(204);
// 确认用户和文章都已删除
const getRes = await testApp.request.get('/users/1');
expect(getRes.status).toBe(404);
});
});#Drizzle 高级用法
#关联查询(Relations API)
Drizzle 支持声明式的关联查询,避免手动 JOIN:
import { relations } from 'drizzle-orm';
import { users, posts } from './schema.js';
// 声明关联关系
export const usersRelations = relations(users, ({ many }) => ({
posts: many(posts),
}));
export const postsRelations = relations(posts, ({ one }) => ({
author: one(users, {
fields: [posts.authorId],
references: [users.id],
}),
}));
// 使用关联查询
const usersWithPosts = await db.query.users.findMany({
with: {
posts: {
where: eq(posts.published, true),
orderBy: desc(posts.createdAt),
limit: 5,
},
},
});#事务
import { eq, sql } from 'drizzle-orm';
// 事务:转账操作
async function transfer(fromId: number, toId: number, amount: number) {
await db.transaction(async (tx) => {
// 扣减
tx.update(accounts)
.set({ balance: sql`balance - ${amount}` })
.where(eq(accounts.id, fromId))
.run();
// 增加
tx.update(accounts)
.set({ balance: sql`balance + ${amount}` })
.where(eq(accounts.id, toId))
.run();
// 记录交易
tx.insert(transactions)
.values({ fromId, toId, amount, type: 'transfer' })
.run();
});
}#子查询与聚合
import { eq, count, avg, gt, sql } from 'drizzle-orm';
// 统计每个用户的文章数
const userPostCounts = db
.select({
userId: users.id,
name: users.name,
postCount: count(posts.id),
})
.from(users)
.leftJoin(posts, eq(users.id, posts.authorId))
.groupBy(users.id)
.having(gt(count(posts.id), 0))
.all();
// 子查询:查询文章数大于平均值的用户
const avgPostCount = db
.select({ avg: avg(count(posts.id)) })
.from(posts)
.groupBy(posts.authorId);
const activeAuthors = db
.select()
.from(users)
.where(
gt(
db
.select({ count: count() })
.from(posts)
.where(eq(posts.authorId, users.id)),
sql`(SELECT AVG(cnt) FROM (SELECT COUNT(*) as cnt FROM posts GROUP BY author_id))`,
),
)
.all();#动态条件构建
import { and, or, eq, like, gte, lte, sql } from 'drizzle-orm';
import type { SQL } from 'drizzle-orm';
interface UserFilter {
keyword?: string;
role?: string;
minAge?: number;
maxAge?: number;
}
function buildUserFilter(filter: UserFilter): SQL | undefined {
const conditions: SQL[] = [];
if (filter.keyword) {
conditions.push(
or(
like(users.name, `%${filter.keyword}%`),
like(users.email, `%${filter.keyword}%`),
)!,
);
}
if (filter.role) {
conditions.push(eq(users.role, filter.role));
}
if (filter.minAge !== undefined) {
conditions.push(gte(users.age, filter.minAge));
}
if (filter.maxAge !== undefined) {
conditions.push(lte(users.age, filter.maxAge));
}
return conditions.length > 0 ? and(...conditions) : undefined;
}
// 使用
const items = db
.select()
.from(users)
.where(buildUserFilter({ keyword: 'alice', role: 'admin', minAge: 20 }))
.all();#使用 PostgreSQL
切换到 PostgreSQL 只需修改两个文件:
// src/db/schema.ts — 改用 pg 导入
import {
pgTable,
serial,
text,
integer,
timestamp,
boolean,
} from 'drizzle-orm/pg-core';
export const users = pgTable('users', {
id: serial('id').primaryKey(),
name: text('name').notNull(),
email: text('email').notNull().unique(),
age: integer('age'),
role: text('role').notNull().default('user'),
createdAt: timestamp('created_at').notNull().defaultNow(),
updatedAt: timestamp('updated_at').notNull().defaultNow(),
});// src/db/index.ts — 改用 pg 驱动
import { drizzle } from 'drizzle-orm/postgres-js';
import postgres from 'postgres';
import * as schema from './schema.js';
export function createDatabase(config: { url: string }) {
const client = postgres(config.url);
const db = drizzle(client, { schema });
return { db, client };
}业务代码(服务层、路由层)完全无需修改。
#项目模式总结
| 层级 | 职责 | 文件 |
|---|---|---|
| Schema | 表定义 + 类型推断 | src/db/schema.ts |
| 连接工厂 | 创建数据库实例 | src/db/index.ts |
| 插件 | 初始化连接、挂载 app.db、清理 | src/plugins/database.ts |
| 服务层 | 业务逻辑 + Drizzle 查询 | src/services/user.ts |
| 路由层 | 请求校验 + 调用服务 + 响应 | src/routes/users.ts |
| 类型声明 | app.db 类型扩展 | types/vext.d.ts |
#核心原则
- Schema 即类型:使用
$inferSelect/$inferInsert从 Drizzle schema 推断类型,不手动维护接口 - 插件管理生命周期:数据库连接的创建和销毁由插件负责,通过
app.extend()和app.onClose()管理 - 服务层封装查询:所有 Drizzle 查询封装在服务层,路由层只做编排
- 路由层声明式:路由专注于校验规则、文档配置和服务调用
#下一步
- 📖 Prisma ORM 集成 — 另一种流行的 ORM 集成方案
- 📖 Zod 校验集成 — 更强大的参数校验
- 📖 插件 — 深入了解 VextJS 插件系统
- 📖 服务层 — 深入了解服务层设计模式
- 📖 测试 — 测试数据库相关的业务逻辑