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"],
  }],
});

好处:

不要把 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。

可靠策略有三种:

  1. 先分页主表 ID,再用第二条查询加载关联。 最清楚,也便于控制。
  2. 使用 Sequelize 生成的子查询并验证 SQL。 适合简单 include。
  3. 对 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。

改进方式:

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. 初学者高频错误

  1. 把 include 当作数据库自动执行的“魔法”,不看生成 SQL。
  2. 定义用了 as: "admin",查询却写另一个别名。
  3. include 内写 where 后,没意识到没有关联的主表行消失了。
  4. 一对多列表用 findAndCountAll() 却不检查 count 是否重复。
  5. limit + hasMany 后只验证第一页,没验证跨页重复或遗漏。
  6. 为消灭 N+1 做一个包含全部对象的巨大 JOIN,造成更严重的数据膨胀。
  7. 默认查询全部关联字段,意外加载敏感字段或大文本。
  8. 认为 raw: true 仍会返回带关联方法和嵌套类型的 Model 实例。

14. 动手练习

  1. 查询最近 50 条登录审计,并只返回管理员的 id、username、display_name。
  2. 分别用 required: false/true 查询,插入一个没有审计的管理员,比较结果。
  3. 查询至少有一条失败审计的管理员;再改成保留所有管理员但只加载失败审计。
  4. 为管理员列表加入 count,制造一个管理员 3 条审计,验证有无 distinct 的差异。
  5. 写出“每个管理员最近 5 条审计”的两种方案:separate: true 和先分页 ID 后批量查询。
  6. 打开 SQL 日志,数一数循环查询、include、批量 IN 三种方案各执行几条 SQL。

15. 复盘问题

更多当前练习案例见 22 include 参数实战、23 JOIN 分页与计数、25 聚合放大问题。