03 Sequelize 模型与关联
本章练习把新增的 admin_user 和 Sakila 现有表映射成 Sequelize Model。重点是准确描述真实数据库,而不是让 Sequelize 自动创造结构。
1. 初始化顺序
推荐:
创建 Sequelize 实例
-> 初始化全部 Model
-> 建立全部 Association
-> sequelize.authenticate()
-> 启动 HTTP Server
所有 Model 初始化后再建立关联,可以减少循环 import 和“关联目标尚未初始化”的问题。
export function initializeModels(sequelize: Sequelize): void {
initAdminUserModel(sequelize);
initActorModel(sequelize);
initFilmModel(sequelize);
// TODO:其余 Sakila Model
initializeAssociations();
}
2. AdminUser 类骨架
import {
CreationOptional,
DataTypes,
InferAttributes,
InferCreationAttributes,
Model,
type Sequelize,
} from "sequelize";
export class AdminUser extends Model<
InferAttributes<AdminUser>,
InferCreationAttributes<AdminUser>
> {
declare id: CreationOptional<string>;
declare username: string;
declare passwordHash: string;
declare displayName: string;
declare email: string | null;
declare accountStatus: CreationOptional<AdminAccountStatus>;
declare tokenVersion: CreationOptional<number>;
declare failedLoginCount: CreationOptional<number>;
declare lockedUntil: Date | null;
declare lastLoginAt: Date | null;
declare passwordChangedAt: CreationOptional<Date>;
declare createdAt: CreationOptional<Date>;
declare updatedAt: CreationOptional<Date>;
}
为什么 id 是 string:MySQL BIGINT 可能超过 JavaScript 安全整数。为什么 CreationOptional:数据库会生成主键或默认值,创建实例时可不传,不代表读取后它是 undefined。
但 declare id: string 只约束 TypeScript,不能改变 mysql2 的运行时返回值。连接配置需要明确并实际测试 dialectOptions.supportBigNumbers 与 dialectOptions.bigNumberStrings;在 DTO 和 JWT 边界仍使用 String(admin.id) 统一类型。不要先调用 Number(admin.id),否则超出安全整数范围时精度已经丢失。尤其要测试普通查询与自增插入后返回的 ID 类型是否一致。
但 declare id: string 只约束 TypeScript,不能改变 mysql2 的运行时返回值。连接配置需要明确并实际测试 dialectOptions.supportBigNumbers 与 dialectOptions.bigNumberStrings;在 DTO 和 JWT 边界仍使用 String(admin.id) 统一类型。不要先调用 Number(admin.id),否则超出安全整数范围时精度已经丢失。尤其要测试普通查询与自增插入后返回的 ID 类型是否一致。
3. AdminUser.init() 骨架
export function initAdminUserModel(
sequelize: Sequelize,
): typeof AdminUser {
AdminUser.init(
{
id: {
type: DataTypes.BIGINT.UNSIGNED,
primaryKey: true,
autoIncrement: true,
},
username: {
type: DataTypes.STRING(50),
allowNull: false,
// TODO:是否在 Model 重复 unique 约束?说明数据库约束才是最终保护
},
passwordHash: {
type: DataTypes.STRING(255),
allowNull: false,
field: "password_hash",
},
displayName: {
// TODO
},
email: {
// TODO
},
accountStatus: {
// TODO
},
tokenVersion: {
// TODO
},
failedLoginCount: {
// TODO
},
lockedUntil: {
// TODO
},
lastLoginAt: {
// TODO
},
passwordChangedAt: {
// TODO
},
createdAt: {
// TODO:DATETIME(3) 和 field
},
updatedAt: {
// TODO:DATETIME(3) 和 field
},
},
{
sequelize,
tableName: "admin_user",
freezeTableName: true,
// createdAt/updatedAt 已作为普通属性映射到数据库列,
// 值由 DDL 中的 DEFAULT 和 ON UPDATE 维护。
timestamps: false,
defaultScope: {
attributes: {
exclude: ["passwordHash"],
},
},
scopes: {
withPassword: {
attributes: {
include: ["passwordHash"],
},
},
},
},
);
return AdminUser;
}
调用具名 scope 时,不要想当然地认为它只是在 defaultScope 上“加回”一个字段;scope 的替换、合并方式与调用方式有关。这里保留了一个需要亲自验证的练习点:打开开发环境 SQL 日志,确认 withPassword 生成的 SELECT 是否包含密码字段。正式实现时更推荐为登录查询写明确的字段白名单。
defaultScope 只是降低误查密码摘要的概率,不是安全边界。unscoped()、Raw Query 或其他 scope 仍可能查出该字段;即使登录查询取得了摘要,也只能把 Model 交给认证逻辑,不能直接 res.json(model),响应必须经过 DTO 白名单转换。
登录查询示意:
const admin = await AdminUser.scope("withPassword").findOne({
where: { username },
});
普通查询不应携带密码摘要。
4. Sakila 时间戳不是 Sequelize 默认结构
Sakila 很多表只有:
last_update
而不是:
createdAt
updatedAt
因此 Actor 不能照搬 admin_user 的 timestamps 配置。
export class Actor extends Model<
InferAttributes<Actor>,
InferCreationAttributes<Actor>
> {
declare actorId: CreationOptional<number>;
declare firstName: string;
declare lastName: string;
declare lastUpdate: CreationOptional<Date>;
}
export function initActorModel(sequelize: Sequelize): typeof Actor {
return Actor.init(
{
actorId: {
// TODO:SMALLINT UNSIGNED、主键、自增和 field
},
firstName: {
// TODO:真实长度、allowNull、field
},
lastName: {
// TODO
},
lastUpdate: {
// TODO:映射 last_update
},
},
{
sequelize,
tableName: "actor",
timestamps: false,
},
);
}
如果忘记 timestamps: false,Sequelize 可能查询并不存在的 createdAt、updatedAt。
5. 第一阶段模型清单
按练习进度添加,不要一次完成所有表:
认证:AdminUser
Actor CRUD:Actor、FilmActor、Film
Customer:Customer、Address、City、Country、Store
Film:Language、Category、FilmCategory、Inventory
Rental:Rental、Staff
Payment:Payment
字段定义必须对照你本机 Sakila:
SHOW CREATE TABLE actor;
DESCRIBE actor;
不要根据字段名字猜类型和 NULL 规则。
6. Association 统一入口
export function initializeAssociations(): void {
Customer.belongsTo(Address, {
foreignKey: "addressId",
as: "address",
});
Address.hasMany(Customer, {
foreignKey: "addressId",
as: "customers",
});
Film.belongsToMany(Actor, {
through: FilmActor,
foreignKey: "filmId",
otherKey: "actorId",
as: "actors",
});
Actor.belongsToMany(Film, {
through: FilmActor,
foreignKey: "actorId",
otherKey: "filmId",
as: "films",
});
// TODO:其余关联
}
使用 include 时 alias 必须一致:
include: [{ model: Address, as: "address" }]
不要一处写 address,另一处写 customerAddress。
7. 中间表与复合主键
film_actor 与 film_category 是多对多中间表。需要对照真实 DDL 定义:
- 两个外键组成复合主键;
last_update;timestamps: false;tableName明确;belongsToMany中的foreignKey/otherKey方向正确。
查询演员电影时通常不需要把中间表字段返回 API:
through: {
attributes: [],
}
但不要因为响应不需要就省略数据库 Model 的主键映射。
8. DECIMAL、TINYINT 与日期
DECIMAL
payment.amount、film.rental_rate 等值可能以 string 返回。DTO 中先保留 string:
declare amount: string;
TINYINT
customer.active 在数据库可能是数字。可以在 DTO 显式转换为 Boolean,但要保持 Model 与真实数据库一致。
日期
Model 使用 Date,DTO 使用 ISO string:
lastUpdate: actor.lastUpdate.toISOString()
9. 不要隐藏 SQL
学习阶段建议开发环境开启日志:
const sequelize = new Sequelize({
// TODO:连接配置
logging: (sql) => logger.debug({ sql }, "sequelize query"),
});
生产环境是否记录每条 SQL 需考虑敏感参数、日志量和性能。不要把含有密码摘要或隐私字段的完整 SQL 直接写进日志。
10. Model 与 DTO 示例
export interface ActorDto {
actorId: number;
firstName: string;
lastName: string;
fullName: string;
lastUpdate: string;
}
export function toActorDto(actor: Actor): ActorDto {
return {
actorId: actor.actorId,
firstName: actor.firstName,
lastName: actor.lastName,
fullName: `${actor.firstName} ${actor.lastName}`,
lastUpdate: actor.lastUpdate.toISOString(),
};
}
fullName 是 API 派生字段,不一定要存入数据库或写成 Model getter。
11. 本章验收清单
- [ ] 全部 Model 都有明确
tableName。 - [ ] Sakila Model 不会错误查询
createdAt/updatedAt。 - [ ] AdminUser 默认查询不包含密码摘要。
- [ ] 登录 scope 能明确取到密码摘要。
- [ ] BIGINT 管理员 ID 按 string 处理。
- [ ] Association 在全部 Model 初始化后建立。
- [ ] include 使用与关联一致的 alias。
- [ ] DECIMAL 没有被随意转为 number。
- [ ] 没有调用自动修改数据库结构的 sync。
12. 本章练习
- 完成
AdminUser.init()中的 TODO,并与 DDL 逐字段对照。 - 完成 Actor Model,运行一次
findByPk()并观察 SQL。 - 故意去掉
timestamps: false,记录产生的数据库错误后恢复。 - 验证普通 AdminUser 查询和
withPasswordscope 的字段差异。 - 定义 FilmActor 复合主键并解释
foreignKey/otherKey方向。 - 建立 Actor 与 Film 多对多关系,只返回电影字段而隐藏 through 字段。
- 检查 Payment amount 的运行时类型,并使 DTO 类型与它一致。
- 制造一个 alias 不匹配错误,阅读 Sequelize 的错误信息并解释原因。