08B Actor 与 Film:Sequelize 多对多建模与核心 API
上一章已经确认数据库关系:
actor 1 ─── N film_actor N ─── 1 film
这一章把它映射成 Sequelize 6 + TypeScript。示例遵循当前项目约定:Model 属性、
数据库列和 API 字段均使用 snake_case。
1. 为什么建议显式创建 FilmActor Model
只为了最简单的 include,through: "film_actor" 有时也能工作。但当前项目更适合
显式 Model:
- 真实表有复合主键;
- 真实表有
last_update; - 需要直接创建、删除和查询关系;
- 需要准确声明
timestamps: false; - 需要捕获关系表自己的约束错误;
- 后续可能给关系增加业务字段。
“响应不想返回中间表字段”不等于“不需要中间表 Model”。响应可以使用
through: { attributes: [] } 隐藏字段。
2. 最小 Film Model
第 10 章会完成 Film 查询。本章只需要查询已有电影,因此可以先建立只读所需的最小
模型。不要用这个不完整模型调用 Film.create(),因为真实 film 还有
language_id、rental_rate 等必填列。
// models/film.ts
import {
CreationOptional,
DataTypes,
InferAttributes,
InferCreationAttributes,
Model,
type NonAttribute,
} from "sequelize";
import db from "../db";
import type Actor from "./actor";
export class Film extends Model<
InferAttributes<Film>,
InferCreationAttributes<Film>
> {
declare film_id: CreationOptional<number>;
declare title: string;
declare release_year: number | null;
declare last_update: CreationOptional<Date>;
// undefined:本次查询没有 include;[]:已经加载但没有演员。
declare actors?: NonAttribute<Actor[]>;
}
Film.init(
{
film_id: {
type: DataTypes.SMALLINT.UNSIGNED,
primaryKey: true,
autoIncrement: true,
},
title: {
type: DataTypes.STRING(128),
allowNull: false,
},
release_year: {
// Sequelize 6 没有专门的 YEAR DataType;这里用 INTEGER 读取 MySQL YEAR。
// 这是本章最小只读映射,不表示数据库列类型应改成 INT。
type: DataTypes.INTEGER,
allowNull: true,
},
last_update: {
type: DataTypes.DATE,
allowNull: false,
defaultValue: DataTypes.NOW,
},
},
{
sequelize: db,
tableName: "film",
freezeTableName: true,
timestamps: false,
},
);
export default Film;
Model 可以只映射查询需要的列,但这不代表数据库只有这些列。写入 Model 前必须重新 对照完整 DDL。
3. FilmActor 复合主键 Model
// models/film_actor.ts
import {
CreationOptional,
DataTypes,
ForeignKey,
InferAttributes,
InferCreationAttributes,
Model,
} from "sequelize";
import db from "../db";
import type Actor from "./actor";
import type Film from "./film";
export class FilmActor extends Model<
InferAttributes<FilmActor>,
InferCreationAttributes<FilmActor>
> {
declare actor_id: ForeignKey<Actor["actor_id"]>;
declare film_id: ForeignKey<Film["film_id"]>;
declare last_update: CreationOptional<Date>;
}
FilmActor.init(
{
actor_id: {
type: DataTypes.SMALLINT.UNSIGNED,
allowNull: false,
primaryKey: true,
},
film_id: {
type: DataTypes.SMALLINT.UNSIGNED,
allowNull: false,
primaryKey: true,
},
last_update: {
type: DataTypes.DATE,
allowNull: false,
defaultValue: DataTypes.NOW,
},
},
{
sequelize: db,
tableName: "film_actor",
freezeTableName: true,
timestamps: false,
},
);
export default FilmActor;
关键点:
- 两个字段都要写
primaryKey: true; - 不要假设存在默认的
id列; - 不要让 Sequelize 查询
createdAt、updatedAt; ForeignKey<T>是 TypeScript 标记,不创建真实外键;- 真实约束仍以
SHOW CREATE TABLE film_actor为准。
Sequelize 6 对复合主键的部分便捷 API 支持有限。查询一条关系时,不要依赖单值
findByPk(),直接写完整 where 更清楚:
const relation = await FilmActor.findOne({
where: { actor_id, film_id },
});
4. 在 Actor 声明关联属性和 Mixin
belongsToMany() 会在运行时给 Actor 实例安装方法,但 TypeScript 不会自动知道这些
方法,需要在类中声明实际会使用的部分:
import type {
BelongsToManyAddAssociationMixin,
BelongsToManyCountAssociationsMixin,
BelongsToManyGetAssociationsMixin,
BelongsToManyHasAssociationMixin,
BelongsToManyRemoveAssociationMixin,
BelongsToManySetAssociationsMixin,
NonAttribute,
} from "sequelize";
import type Film from "./film";
// Actor class 内:
declare films?: NonAttribute<Film[]>;
declare getFilms: BelongsToManyGetAssociationsMixin<Film>;
declare countFilms: BelongsToManyCountAssociationsMixin;
declare hasFilm: BelongsToManyHasAssociationMixin<Film, number>;
declare addFilm: BelongsToManyAddAssociationMixin<Film, number>;
declare removeFilm: BelongsToManyRemoveAssociationMixin<Film, number>;
declare setFilms: BelongsToManySetAssociationsMixin<Film, number>;
只声明会用到的方法,避免为了“完整”堆一页类型。
4.1 films? 为什么是可选
actor.films === undefined
→ 这次查询没有加载 films,可能是代码忘记 include
actor.films 是 []
→ 已经加载 films,但这个演员没有电影
不要总用 actor.films ?? [] 掩盖二者差异。DTO mapper 本来要求已经 include 时,遇到
undefined 更适合抛出内部错误,及时暴露查询遗漏。
5. 集中注册双向 Association
Model 必须全部完成 init() 后,再注册关联:
// models/associations.ts
import Actor from "./actor";
import Film from "./film";
import FilmActor from "./film_actor";
let initialized = false;
export function initializeModelAssociations(): void {
if (initialized) return;
Actor.belongsToMany(Film, {
through: FilmActor,
as: "films",
foreignKey: "actor_id",
otherKey: "film_id",
});
Film.belongsToMany(Actor, {
through: FilmActor,
as: "actors",
foreignKey: "film_id",
otherKey: "actor_id",
});
initialized = true;
}
反向定义时,foreignKey 与 otherKey 必须对调:
Actor 视角:自己的键 actor_id,对方的键 film_id
Film 视角:自己的键 film_id,对方的键 actor_id
在应用创建阶段、第一次使用任何关联查询之前调用一次:
initializeModelAssociations();
集中初始化可以减少 CommonJS 循环 import。测试多次调用应用工厂时也要防止重复注册 相同 alias。
如果要直接从关系行 include 两端实体,可以再定义:
FilmActor.belongsTo(Actor, {
as: "actor",
foreignKey: "actor_id",
});
FilmActor.belongsTo(Film, {
as: "film",
foreignKey: "film_id",
});
6. as 是关联身份,不是装饰文字
定义时使用:
as: "films"
查询时必须完全一致:
include: [{ model: Film, as: "films" }]
写成 as: "movies" 会得到 eager loading error。一个 Model 可能用不同身份多次关联
同一个目标,例如 created_by、updated_by,所以 Sequelize 不能替你猜 alias。
7. 使用 include 查询关联
const actor = await Actor.findByPk(actor_id, {
attributes: ["actor_id", "first_name", "last_name"],
include: [
{
model: Film,
as: "films",
attributes: ["film_id", "title", "release_year"],
through: {
attributes: [],
},
required: false,
},
],
});
近似 SQL:
SELECT
a.actor_id,
a.first_name,
a.last_name,
f.film_id,
f.title,
f.release_year
FROM actor AS a
LEFT JOIN film_actor AS fa
ON a.actor_id = fa.actor_id
LEFT JOIN film AS f
ON f.film_id = fa.film_id
WHERE a.actor_id = ?;
through.attributes: [] 只是不把中间表字段组装到返回对象,JOIN 仍然存在。
如果 API 需要关系创建时间:
through: {
attributes: ["last_update"],
}
此时要为关系数据单独设计 DTO,不建议把 Sequelize 自动挂载的中间表对象原样返回。
8. required 如何改变 JOIN
required: false // 通常是 LEFT OUTER JOIN
required: true // INNER JOIN
查“全部演员,并附带电影”使用 LEFT JOIN。查“至少出演过标题以 A 开头电影的演员”则 需要 INNER JOIN:
Actor.findAll({
include: [
{
model: Film,
as: "films",
required: true,
where: {
title: { [Op.like]: "A%" },
},
through: { attributes: [] },
},
],
});
include 内写 where 时 Sequelize 常会改变 required 行为。重要查询应显式写
required,并观察生成 SQL,不要只根据 JavaScript 对象猜测 JOIN 类型。
9. 高频 Association Mixin
以下 SQL 是帮助理解的近似形式。Sequelize 可能因为版本、方言和选项产生额外 SELECT, 应在开发环境打开 SQL 日志核对。
9.1 getFilms()
const films = await actor.getFilms({
attributes: ["film_id", "title"],
joinTableAttributes: [],
});
近似 SQL:
SELECT f.film_id, f.title
FROM film AS f
JOIN film_actor AS fa ON fa.film_id = f.film_id
WHERE fa.actor_id = ?;
9.2 countFilms() 与 hasFilm()
const count = await actor.countFilms();
const exists = await actor.hasFilm(film_id);
分别对应关联数量查询和指定关系存在查询。它们不会创建或删除任何数据。
9.3 addFilm()
await actor.addFilm(film_id);
它只建立关系,不创建 Film。近似行为:
SELECT ... FROM film_actor WHERE actor_id = ? AND film_id = ?;
INSERT INTO film_actor (actor_id, film_id) VALUES (?, ?);
并发情况下仍可能在 INSERT 时遇到重复键,所以不能因为 ORM 做了预查询就忽略
UniqueConstraintError。
9.4 removeFilm()
await actor.removeFilm(film_id);
近似 SQL:
DELETE FROM film_actor WHERE actor_id = ? AND film_id = ?;
它不会删除 Film,也不会删除 Actor。
9.5 setFilms():最容易误用
await actor.setFilms([1, 2, 3]);
它不是“新增 1、2、3”,而是“把完整电影集合替换成 1、2、3”:
读取当前关系
→ 删除新列表中不存在的旧关系
→ 插入当前不存在的新关系
setFilms([]) 会清空全部关系。只有 PUT 整体替换语义才适合它,普通新增一条关系
不要使用 setFilms()。
9.6 createFilm()
await actor.createFilm(film_input);
它会创建新 Film,再建立关系。Sakila Film 有很多必填列,因此只有提供完整电影创建
数据时才能使用。本练习“关联已有电影”不要误用 createFilm()。
10. 直接操作 FilmActor 还是使用 Mixin
| 需求 | 更直观的选择 |
|---|---|
| 简单读取演员的电影 | actor.getFilms() 或 include |
| 建立已有两端的关系 | FilmActor.create() 或 actor.addFilm() |
| 需要 DELETE 影响行数 | FilmActor.destroy() |
| 更新关系自身属性 | FilmActor.update() |
| 整体替换关系集合 | actor.setFilms(),必须谨慎 |
| 中间表是独立业务实体 | 直接以 FilmActor 为查询主体 |
初学阶段建议先直接使用 FilmActor.create/destroy,因为复合键、影响行数和错误来源
更容易观察;理解后再使用 Mixin 简化表达。
11. 多对多查询的分页和计数陷阱
一个 Actor 有 20 部电影,SQL JOIN 后会产生 20 行。Sequelize 最终可能把它们组装成 一个 Actor 对象,但 SQL 的分页与 count 已经受到影响。
Actor.findAndCountAll({
include: [
{
model: Film,
as: "films",
through: { attributes: [] },
},
],
distinct: true,
col: "actor_id",
});
distinct: true 常用于让 count 按 Actor 去重,但它不保证所有复杂分页自动正确。仍需
检查生成 SQL,并测试一位演员有很多电影时的页大小、跨页重复和总数。
如果接口主体是“某演员的电影列表”,更简单的做法是以 Film 为分页主体,JOIN
film_actor 并限定 actor_id,而不是分页 Actor 再展开 Film。
12. N+1 问题
错误模式:
const actors = await Actor.findAll();
for (const actor of actors) {
await actor.getFilms();
}
100 个演员会产生 1 + 100 条查询。可以根据响应形状选择:
- 一次 include;
- 先分页 Actor ID,再用一次
WHERE actor_id IN (...)查询全部关系; - 以 FilmActor 为主体批量查询并在 Node.js 分组。
不要为了“只有几行代码”忽略 SQL 查询数量。
13. 本章验收
- [ ]
FilmActor没有虚构的id字段。 - [ ] 两个外键都映射为复合主键。
- [ ] 三个 Model 都显式设置
tableName和timestamps: false。 - [ ] 两个
belongsToMany的 key 方向正确。 - [ ] Association 只在所有 Model 初始化后注册一次。
- [ ] include 的
as与定义完全一致。 - [ ] 能解释
undefined与空关联数组的区别。 - [ ] 能解释
addFilm和createFilm的区别。 - [ ] 能说明
setFilms()会删除哪些旧关系。 - [ ] 打开 SQL 日志核对每个 Mixin 的真实行为。
下一章使用这些 Model 完成可调用的关联管理接口、错误映射和事务替换。