22 include 参数实战:连接数据与筛选主体

对应项目:customerAddressInclude()、listActorFilms()、关联注册。

1. 先确定“这条列表的一项是什么”

客户列表的一项是 Customer,适合 Customer.findAll()。演员的电影列表可以以 FilmActor 为主体,每一项表示一个演员与电影的关系。

不要先问“能不能把所有表 JOIN 到一起”。先写出主体、关联基数和返回形状:

Customer → Address → City → Country:每一段都是 belongsTo,最多一个对象
Customer → Rental:hasMany,一个客户可以有多条租赁
Actor ↔ Film:belongsToMany,经过 FilmActor
FilmActor → Film:belongsTo,每条关系对应一部电影

前一条链通常不放大客户行;多值关联可能放大。这个区别会影响计数、分页和聚合。

2. model、as 与 association

const customers = await Customer.findAll({
  attributes: ["customer_id", "first_name"],
  include: [{
    model: Address,
    as: "address",
    attributes: ["address_id", "address"],
  }],
  limit: 10,
});

等价 SQL:

SELECT c.customer_id, c.first_name, a.address_id, a.address
FROM customer AS c
LEFT JOIN address AS a ON c.address_id = a.address_id
LIMIT 10;

默认得到实例,关联挂在 customer.address。as 必须与注册时的名字一致,不是任意 SQL 别名。

也可以明确引用已注册关联:

include: [{
  association: Customer.associations.address!,
  attributes: ["address_id", "address"],
}]

这里非空断言依赖“关联已经初始化”的前提,并不验证初始化成功。初学阶段使用 model + as 更直观;字符串 association: "address" 也可用,但拼错仍是运行时错误。

include 不会自动创建模型、外键或实际表。它使用已经定义的 Association 组织查询。

3. required 决定是否排除主体记录

查询所有客户,只附带未归还租赁

include: [{
  model: Rental,
  as: "rentals",
  attributes: ["rental_id", "return_date"],
  where: { return_date: null },
  required: false,
}]

等价 SQL:

SELECT c.customer_id, r.rental_id, r.return_date
FROM customer AS c
LEFT JOIN rental AS r
  ON r.customer_id = c.customer_id AND r.return_date IS NULL;

没有未归还租赁的客户仍返回,通常 rentals: []。条件限制的是附带的数据。

只查询有未归还租赁的客户

把 required 改成 true:

SELECT c.customer_id, r.rental_id, r.return_date
FROM customer AS c
INNER JOIN rental AS r
  ON r.customer_id = c.customer_id AND r.return_date IS NULL;

没有匹配租赁的客户被排除。include.where 存在且没有显式 required 时,本版本通常默认 required: true。因此“只加一个子表 where”可能改变父表列表含义。

4. include.where 与顶层 where 的差别

上面的 required: false + include.where 是“保留客户,但只加载匹配租赁”。

顶层路径条件则放到整个查询的 WHERE:

const customers = await Customer.findAll({
  where: { "$rentals.rental_id$": { [Op.ne]: null } },
  include: [{ model: Rental, as: "rentals", required: false }],
});
SELECT ... FROM customer AS c
LEFT JOIN rental AS r ON r.customer_id = c.customer_id
WHERE r.rental_id IS NOT NULL;

客户没有租赁时,连接产生的 NULL 被 WHERE 排除。虽然 JOIN 写的是 LEFT,最终结果已不保留这些客户。

特别易错的是顶层 return_date IS NULL:LEFT JOIN 没有租赁也会生成 NULL,可能把“完全没有租赁”的客户算进去。要区分“有未归还租赁”和“没有关联”。

顶层 $关联.字段$ 在开启分页子查询后还可能遇到别名作用域问题。不是所有 JOIN 条件都应移动到顶层;先看实际 SQL。

5. Customer 国家筛选:沿途都要参与筛选

国家外键 country_id 在 City,不在 Customer。

include: [{
  model: Address,
  as: "address",
  required: true,
  include: [{
    model: City,
    as: "city",
    required: true,
    where: { country_id },
    include: [{ model: Country, as: "country", required: true }],
  }],
}]

等价 SQL:

SELECT ...
FROM customer AS c
INNER JOIN address AS a ON a.address_id = c.address_id
INNER JOIN city AS ci ON ci.city_id = a.city_id AND ci.country_id = :country_id
INNER JOIN country AS co ON co.country_id = ci.country_id;

如果最外层 Address 仍是 LEFT,即使内部 City 是 INNER,外层仍可能保留没有匹配路径的客户,只是地址为 null。最深层 required 不自动把所有父层变成 INNER。

当前 include 工厂:没有国家筛选时加载资料;有筛选时沿途 required=true。工厂复用的是完整查询意图,不只是省几个字符。

6. belongsToMany 的 through

const actor = await Actor.findByPk(actor_id, {
  attributes: ["actor_id", "first_name", "last_name"],
  include: [{
    model: Film,
    as: "films",
    attributes: ["film_id", "title"],
    through: { attributes: [] },
    required: false,
  }],
});

等价 SQL 要经过中间表:

SELECT a.actor_id, a.first_name, a.last_name, f.film_id, f.title
FROM actor AS a
LEFT JOIN film_actor AS fa ON fa.actor_id = a.actor_id
LEFT JOIN film AS f ON f.film_id = fa.film_id
WHERE a.actor_id = :actor_id;

through.attributes: [] 只是不给结果附带中间表字段,不是跳过中间表 JOIN。

若中间表有需要的字段,例如当前 last_update:

through: {
  attributes: ["last_update"],
  where: { last_update: { [Op.gte]: start_at } },
}

条件作用于 film_actor,不是 film。是否保留没有匹配关系的演员仍需结合 required 和实际 SQL 判断。

当前电影列表直接以 FilmActor 为基表:

const rows = await FilmActor.findAll({
  where: { actor_id },
  include: [{ model: Film, as: "film", required: true }],
  order: [
    [{ model: Film, as: "film" }, "title", "ASC"],
    ["film_id", "ASC"],
  ],
  limit: 20,
});

固定 actor_id 时,复合主键保证每个 film_id 只有一条关系;include Film 是单值,因此一条关系对应一条列表项,分页更容易理解。

7. 关联排序放在哪里?

普通 JOIN 加载时,关联排序通常写在顶层:

order: [[{ model: Film, as: "films" }, "title", "ASC"]]

多层关联就按关联路径提供多个 model/as 项。不要把普通 JOIN 的 order 随意放进 include,以为每个子数组一定按它排序。

separate: true 的 hasMany include 则支持在 include 内设置 order。也要区分“按电影标题排列关联数组”和“按电影某个聚合值排列演员”,后者通常需要单独定义聚合规则。

8. separate 与 include.limit:不是所有关联都支持

需求:取 20 个客户,每个客户最多附带最近 3 条租赁。

const customers = await Customer.findAll({
  attributes: ["customer_id", "first_name"],
  order: [["customer_id", "ASC"]],
  limit: 20,
  include: [{
    model: Rental,
    as: "rentals",
    attributes: ["rental_id", "customer_id", "rental_date"],
    separate: true,
    limit: 3,
    order: [["rental_date", "DESC"], ["rental_id", "DESC"]],
  }],
});

先查父记录,再查关联记录。关联查询可能使用批量条件或分组限制的 UNION 等 SQL,不承诺“永远只有两条简单查询”。

Sequelize v6 当前实现只允许 hasMany 使用 separate。不能把它直接加到 Actor.belongsToMany(Film)。include.limit 在未显式设置 separate 时可能触发 separate,从而报 Only HasMany associations support include.separate。

separate 子查询的 where 限制子数据,不应当拿它当作父表“必须有匹配孩子”的过滤方案;父表筛选可另用 EXISTS 或必要的 JOIN。

9. subQuery 与 duplicating:先别拿来救火

如果租赁真的一对多,强行标记 duplicating=false 是错误承诺。先选分页主体,再考虑“父表分页后批量加载关联”、separate 或专门 SQL。详见 23 章。

10. 练习

  1. 比较 Rental where + required=false / true,解释没有租赁的客户去哪里了。
  2. 国家筛选只改最深层 required,再与沿途 required=true 对照。
  3. 取两个客户及所有租赁,观察 SQL 行数与实例数组长度为何不同。
  4. 在 FilmActor 基表查询中增加电影标题过滤,确认过滤列的所属表。

下一章:排序、分页与计数。参考:v6 Eager Loading。