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:

“响应不想返回中间表字段”不等于“不需要中间表 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;

关键点:

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 条查询。可以根据响应形状选择:

不要为了“只有几行代码”忽略 SQL 查询数量。

13. 本章验收

下一章使用这些 Model 完成可调用的关联管理接口、错误映射和事务替换。