13. 使用 include 完成日常连表查询
上一章定义了 AdminUser.hasMany(AdminLoginAudit) 和反向 belongsTo。本章集中处理日常查询:include、INNER/LEFT JOIN、关联条件、分页、计数与 N+1。
1. include 是预加载,不是随时自动查询
const audit = await AdminLoginAudit.findByPk(audit_id);
这只查询审计表,audit?.admin 不会在访问时自动去数据库查。要加载管理员,必须显式 include:
const audit = await AdminLoginAudit.findByPk(audit_id, {
include: [{ association: "admin" }],
});
这叫 eager loading(预加载)。显式查询能让数据库访问次数可见,避免“读一个属性突然发 SQL”的隐式行为。
2. required 决定 INNER JOIN 还是 LEFT JOIN
const admins = await AdminUser.findAll({
include: [
{
association: "login_audits",
required: false,
},
],
});
required: false 近似:
SELECT ...
FROM admin_user AS admin
LEFT JOIN admin_login_audit AS audit
ON audit.admin_user_id = admin.id;
没有审计记录的管理员也会返回,其 login_audits 是空数组。
改为:
required: true
近似 SQL:
SELECT ...
FROM admin_user AS admin
INNER JOIN admin_login_audit AS audit
ON audit.admin_user_id = admin.id;
这时只返回至少有一条匹配审计的管理员。
3. 关联 where 会改变连接含义
查询至少有一次失败记录的管理员:
import { Op } from "sequelize";
const admins = await AdminUser.findAll({
attributes: ["id", "username", "display_name"],
include: [
{
association: "login_audits",
attributes: ["id", "login_result", "created_at"],
where: { login_result: { [Op.in]: [2, 3, 4] } },
required: true,
},
],
});
近似 SQL:
SELECT ...
FROM admin_user AS admin
INNER JOIN admin_login_audit AS audit
ON audit.admin_user_id = admin.id
AND audit.login_result IN (2, 3, 4);
Sequelize 的 include 内写 where 时,默认常会使该 include 成为 required。为了让意图清楚,教程和业务代码建议显式写 required,不要依赖隐式规则。
如果想返回所有管理员,但数组中只包含失败记录:
include: [{
association: "login_audits",
where: { login_result: { [Op.in]: [2, 3, 4] } },
required: false,
}]
此时条件属于 JOIN 的匹配条件;主表管理员仍全部保留。
4. 查询“从未登录过”的管理员
SQL 思路是 LEFT JOIN 后找右表为空:
SELECT admin.id, admin.username
FROM admin_user AS admin
LEFT JOIN admin_login_audit AS audit
ON audit.admin_user_id = admin.id
WHERE audit.id IS NULL;
Sequelize 可表达为:
import { Op } from "sequelize";
const admins = await AdminUser.findAll({
attributes: ["id", "username"],
include: [{
association: "login_audits",
attributes: [],
required: false,
}],
where: {
"$login_audits.id$": { [Op.is]: null },
},
});
$login_audits.id$ 依赖关联别名,拼写错误只能在运行时暴露。复杂条件若 Sequelize 表达变得晦涩,可以使用带 replacements/bind 的 Raw SQL;ORM 不是必须覆盖所有 SQL 的目标。
5. attributes 应在主表和关联表分别控制
const rows = await AdminLoginAudit.findAll({
attributes: ["id", "login_result", "ip_address", "created_at"],
include: [{
association: "admin",
attributes: ["id", "username", "display_name"],
}],
});
好处:
- 减少网络和对象组装数据量;
- 明确 DTO 所需字段;
- 避免敏感字段泄露;
- 避免未来表增加大字段后列表查询突然变慢。
不要把 defaultScope 当成唯一防线。关联查询同样要审查实际生成的 SELECT。
6. 一对多 JOIN 为什么让主表行重复
一个管理员有三条审计时,数据库原始结果是三行:
admin 1 + audit A
admin 1 + audit B
admin 1 + audit C
Sequelize 通常会把它们组装成一个 AdminUser 和三个 login_audits。但数据库仍处理了三行;聚合、计数、分页都必须考虑重复。
7. findAndCountAll() 与 distinct
管理员列表带一对多关联:
const result = await AdminUser.findAndCountAll({
attributes: ["id", "username", "display_name"],
include: [{
association: "login_audits",
attributes: ["id", "created_at"],
required: false,
}],
distinct: true,
order: [["id", "ASC"]],
limit: page_size,
offset: (page - 1) * page_size,
});
没有 distinct: true 时,count 可能按 JOIN 后的行数计算:管理员 A 有 3 条、B 有 2 条,得到 5 而不是 2。
近似正确计数:
SELECT COUNT(DISTINCT admin.id)
FROM admin_user AS admin
LEFT JOIN admin_login_audit AS audit
ON audit.admin_user_id = admin.id;
注意:distinct 能修正很多简单场景,但复杂 include、group、子查询组合的返回形态仍需实际测试。不要仅看 TypeScript 类型就相信总数正确。
8. 一对多 include 与分页的陷阱
如果直接对 JOIN 结果执行 LIMIT 10,10 行可能全部属于同一个管理员,无法代表 10 个管理员。Sequelize 在某些查询中会生成子查询先分页主表,但复杂排序/条件下仍要检查 SQL。
可靠策略有三种:
- 先分页主表 ID,再用第二条查询加载关联。 最清楚,也便于控制。
- 使用 Sequelize 生成的子查询并验证 SQL。 适合简单 include。
- 对 hasMany 使用
separate: true。 Sequelize 先查主表,再单独查关联。
const admins = await AdminUser.findAll({
attributes: ["id", "username"],
order: [["id", "ASC"]],
limit: 20,
include: [{
association: "login_audits",
separate: true,
limit: 5,
order: [["created_at", "DESC"]],
}],
});
separate: true 适用于 hasMany,便于实现“每个管理员最近 5 条”。它会增加查询次数,但通常不是每行一条查询;仍应通过 SQL 日志确认版本和选项下的行为。
9. N+1 问题
错误写法:
const admins = await AdminUser.findAll(); // 1 次
for (const admin of admins) {
await AdminLoginAudit.findAll({
where: { admin_user_id: admin.id },
}); // N 次
}
100 个管理员会执行 101 次查询,这就是 N+1。
改进方式:
- 使用
include; - 先取得管理员 ID,再用一次
WHERE admin_user_id IN (...)批量查询并在内存分组; - 列表只需要计数时,直接做聚合查询,不加载所有明细。
N+1 不是“查询次数超过 1”就成立。为了正确分页而固定执行两条批量查询,通常比一个巨大 JOIN 更可靠。
10. 聚合审计数量
import { fn, col } from "sequelize";
const admins = await AdminUser.findAll({
attributes: [
"id",
"username",
[fn("COUNT", col("login_audits.id")), "audit_count"],
],
include: [{
association: "login_audits",
attributes: [],
required: false,
}],
group: ["AdminUser.id", "AdminUser.username"],
});
近似 SQL:
SELECT admin.id, admin.username, COUNT(audit.id) AS audit_count
FROM admin_user AS admin
LEFT JOIN admin_login_audit AS audit
ON audit.admin_user_id = admin.id
GROUP BY admin.id, admin.username;
聚合别名不是模型声明的普通属性。读取时可使用:
const audit_count = admin.getDataValue("audit_count" as never);
这种类型体验提示我们:复杂报表查询不一定适合硬塞进实体 Model。可以用 Raw SQL + 明确的结果接口,或创建专门的查询 DTO。
11. 多层 include 与数据爆炸
include: [{
association: "login_audits",
include: [{ association: "some_other_relation" }],
}]
多层 include 是可用的,但两个一对多 JOIN 会产生笛卡尔式放大。例如 10 条审计 × 每条 5 个事件,单个管理员就有 50 行原始结果。应先判断页面真正需要什么,不要把整个对象图一次加载。
12. 多对多的 through
若未来有 AdminUser.belongsToMany(Role, { through: AdminUserRole }),查询时可控制中间表字段:
include: [{
association: "roles",
attributes: ["id", "name"],
through: { attributes: [] },
}]
如果 API 不需要 admin_user_role 的字段,attributes: [] 能避免输出冗余中间对象。若中间表含 granted_at 等业务字段,则应显式选择并建对应 DTO。
13. 初学者高频错误
- 把 include 当作数据库自动执行的“魔法”,不看生成 SQL。
- 定义用了
as: "admin",查询却写另一个别名。 - include 内写
where后,没意识到没有关联的主表行消失了。 - 一对多列表用
findAndCountAll()却不检查 count 是否重复。 limit+ hasMany 后只验证第一页,没验证跨页重复或遗漏。- 为消灭 N+1 做一个包含全部对象的巨大 JOIN,造成更严重的数据膨胀。
- 默认查询全部关联字段,意外加载敏感字段或大文本。
- 认为
raw: true仍会返回带关联方法和嵌套类型的 Model 实例。
14. 动手练习
- 查询最近 50 条登录审计,并只返回管理员的
id、username、display_name。 - 分别用
required: false/true查询,插入一个没有审计的管理员,比较结果。 - 查询至少有一条失败审计的管理员;再改成保留所有管理员但只加载失败审计。
- 为管理员列表加入 count,制造一个管理员 3 条审计,验证有无
distinct的差异。 - 写出“每个管理员最近 5 条审计”的两种方案:
separate: true和先分页 ID 后批量查询。 - 打开 SQL 日志,数一数循环查询、include、批量 IN 三种方案各执行几条 SQL。
15. 复盘问题
required: false与required: true分别接近哪种 JOIN?- include 内的
where为什么可能让主表记录消失? - 一对多 JOIN 为什么会让 count 变大?
distinct修正的是什么? - 为什么分页不能只盯着
limit,还要看它作用于主表还是 JOIN 行? - 固定两条批量查询为什么不属于典型 N+1?
- 哪些场景下 Raw SQL + 查询 DTO 比复杂 include 更清楚?
更多当前练习案例见 22 include 参数实战、23 JOIN 分页与计数、25 聚合放大问题。