6月5日 00:30

TypeORM QueryBuilder高级查询:条件组合、子查询、分页和批量操作

find()findBy() 只能处理简单查询——多表关联、条件组合、子查询、聚合分组,都得用 QueryBuilder。但 QueryBuilder 的 API 设计有时让人困惑:leftJoininnerJoin 有什么区别?whereandWhere 能不能混用?子查询怎么写?这篇文章用实际场景把 QueryBuilder 的高级用法讲清楚。

QueryBuilder 基础回顾

typescript
// 简单查询:find() 能搞定的 const users = await userRepository.find({ where: { active: true }, order: { createdAt: 'DESC' }, take: 20, }); // 复杂查询:必须用 QueryBuilder const users = await userRepository .createQueryBuilder('user') .where('user.active = :active', { active: true }) .orderBy('user.createdAt', 'DESC') .limit(20) .getMany();

'user' 是别名,后续引用这个表都用这个别名。SQL 里变成 SELECT ... FROM user user

条件组合:OR、AND、嵌套

OR 条件

typescript
// 错误:连续写 where,后面的会覆盖前面的 .where('user.active = :active', { active: true }) .where('user.role = :role', { role: 'admin' }) // 生成:WHERE user.role = 'admin'(第一个 where 被覆盖!) // 正确:用 orWhere .where('user.active = :active', { active: true }) .orWhere('user.role = :role', { role: 'admin' }) // 生成:WHERE user.active = true OR user.role = 'admin'

混合 AND + OR

typescript
// 需求:活跃用户,且(管理员 或 创建于2024年之后) .where('user.active = :active', { active: true }) .andWhere( new Brackets(qb => { qb.where('user.role = :role', { role: 'admin' }) .orWhere('user.createdAt > :date', { date: '2024-01-01' }); }) ) // 生成:WHERE user.active = true AND (user.role = 'admin' OR user.createdAt > '2024-01-01')

Brackets 是关键——不加的话 OR 会和前面的 AND 混在一起,逻辑不对。需要括号的地方就用 Brackets

动态条件

根据用户输入动态拼查询条件:

typescript
function buildUserQuery(filters: { name?: string; role?: string; minAge?: number; maxAge?: number; }) { const qb = userRepository.createQueryBuilder('user'); if (filters.name) { qb.andWhere('user.name LIKE :name', { name: `%${filters.name}%` }); } if (filters.role) { qb.andWhere('user.role = :role', { role: filters.role }); } if (filters.minAge) { qb.andWhere('user.age >= :minAge', { minAge: filters.minAge }); } if (filters.maxAge) { qb.andWhere('user.age <= :maxAge', { maxAge: filters.maxAge }); } return qb; }

注意参数占位符:用 :paramName + 对象传参,不要字符串拼接——防止 SQL 注入:

typescript
// ❌ SQL 注入风险 qb.where(`user.name = '${filters.name}'`); // ✅ 参数化查询 qb.where('user.name = :name', { name: filters.name });

关联查询:leftJoin vs innerJoin

leftJoin:左连接,左表数据全保留

typescript
const users = await userRepository .createQueryBuilder('user') .leftJoin('user.posts', 'post') .getMany(); // 即使没有文章的用户也会返回,post 字段为 []

innerJoin:内连接,只返回有关联数据的行

typescript
const usersWithPosts = await userRepository .createQueryBuilder('user') .innerJoin('user.posts', 'post') .getMany(); // 只返回至少有一篇文章的用户

关联数据的选择和过滤

typescript
// 只查用户的文章标题,不加载整个 post 对象 const users = await userRepository .createQueryBuilder('user') .leftJoinAndSelect('user.posts', 'post') .where('post.published = :published', { published: true }) .getMany();

leftJoinAndSelect 会把关联数据一起查出来(自动 SELECT),leftJoin 只 JOIN 不 SELECT。如果你后面要用 post.title 做过滤但不需要返回 post 数据,用 leftJoin;如果需要返回 post 数据,用 leftJoinAndSelect

子查询

WHERE 里的子查询

查"有超过 5 篇文章的用户":

typescript
const users = await userRepository .createQueryBuilder('user') .where((qb) => { const subQuery = qb .subQuery() .select('post.authorId') .from(Post, 'post') .groupBy('post.authorId') .having('COUNT(post.id) > :count') .getQuery(); return `user.id IN ${subQuery}`; }) .setParameter('count', 5) .getMany();

SELECT 里的子查询(关联计数)

查每个用户的文章数:

typescript
const users = await userRepository .createQueryBuilder('user') .leftJoin('user.posts', 'post') .groupBy('user.id') .select('user.id', 'id') .addSelect('user.name', 'name') .addSelect('COUNT(post.id)', 'postCount') .getRawMany(); // 返回: [{ id: 1, name: 'Alice', postCount: '3' }, ...]

注意 getRawMany() 返回原始数据库行,字段名是 select 里指定的别名,类型都是字符串。getMany() 返回实体对象,但聚合查询返回的不是实体,所以必须用 getRawMany()

分页

TypeORM 的 skip + take 是最简单的分页:

typescript
const [users, total] = await userRepository .createQueryBuilder('user') .orderBy('user.createdAt', 'DESC') .skip((page - 1) * pageSize) .take(pageSize) .getManyAndCount();

getManyAndCount() 返回 [数据, 总数]——一次查询做 SELECT + COUNT,比分开两次高效。

深度分页的坑skip(100000).take(20) 在 MySQL 上很慢——数据库要扫描前 100020 行再丢弃前 100000 行。深度分页用 keyset pagination:

typescript
// 不用 offset,用上一页最后一条的 ID const users = await userRepository .createQueryBuilder('user') .where('user.id > :lastId', { lastId: lastIdOfPrevPage }) .orderBy('user.id', 'ASC') .take(20) .getMany();

ID 有索引时,不管翻到第几页都是 O(1)。

聚合和分组

typescript
// 每个分类的文章数 const stats = await postRepository .createQueryBuilder('post') .select('post.category', 'category') .addSelect('COUNT(*)', 'count') .addSelect('MAX(post.createdAt)', 'latestPost') .groupBy('post.category') .having('COUNT(*) > :minCount', { minCount: 1 }) .getRawMany();

where 过滤行(分组前),having 过滤组(分组后)。

窗口函数

TypeORM 的 QueryBuilder 不直接支持窗口函数语法。用 .getRawMany() + 原生 SQL:

typescript
// PostgreSQL:查每个用户的文章数和排名 const ranked = await dataSource.createQueryRunner() .query(` SELECT u.id, u.name, COUNT(p.id) AS post_count, RANK() OVER (ORDER BY COUNT(p.id) DESC) AS rank FROM "user" u LEFT JOIN post p ON p."authorId" = u.id GROUP BY u.id ORDER BY rank `);

或者用 QueryBuilder 的 select 写原始片段:

typescript
const result = await userRepository .createQueryBuilder('user') .leftJoin('user.posts', 'post') .groupBy('user.id') .select('user.id', 'id') .addSelect('user.name', 'name') .addSelect('COUNT(post.id)', 'postCount') .addSelect('RANK() OVER (ORDER BY COUNT(post.id) DESC)', 'rank') .getRawMany();

窗口函数只有 PostgreSQL 和 SQL Server 完整支持,MySQL 8+ 也支持。SQLite 不支持。

批量操作

批量插入

typescript
await userRepository.insert([ { name: 'Alice', email: 'a@test.com' }, { name: 'Bob', email: 'b@test.com' }, { name: 'Charlie', email: 'c@test.com' }, ]);

比循环 save() 快得多——一条 INSERT 语句插入多行。

批量更新

typescript
await userRepository .createQueryBuilder() .update(User) .set({ active: false }) .where('lastLoginAt < :date', { date: sixMonthsAgo }) .execute();

Upsert(PostgreSQL / MySQL)

typescript
// PostgreSQL: ON CONFLICT await userRepository .createQueryBuilder() .insert() .into(User) .values([{ id: 1, name: 'Alice', email: 'new@test.com' }]) .onConflict('("id") DO UPDATE SET "email" = EXCLUDED."email"') .execute(); // MySQL: ON DUPLICATE KEY UPDATE await userRepository .createQueryBuilder() .insert() .into(User) .values([{ id: 1, name: 'Alice', email: 'new@test.com' }]) .orUpdate(['email'], ['id']) .execute();

Upsert 的语法在 PostgreSQL 和 MySQL 上不同——这是 TypeORM "一套代码多数据库" 最大的例外之一。

标签:TypeORM