04|Model.init() 与表映射配置
TypeScript 类声明告诉编译器“实例有哪些属性”,Model.init() 则告诉 Sequelize“这些属性怎样映射到数据库”。两部分缺一不可。
1. Model.init() 的两个参数
当前结构:
AdminUser.init(
{
// 第一个参数:每个字段的映射
},
{
// 第二个参数:整张表/整个模型的配置
sequelize: db,
tableName: "admin_user",
},
);
第一个参数回答“某一列是什么”,第二个参数回答“这个 Model 整体怎样工作”。
2. 高频字段配置
type
username: {
type: DataTypes.STRING(50),
}
它影响 Sequelize 的值处理以及建表 SQL,但不能把已有数据库列自动改成 VARCHAR(50)。
常见映射:
| MySQL | Sequelize | TypeScript |
|---|---|---|
VARCHAR(50) |
DataTypes.STRING(50) |
string |
INT UNSIGNED |
DataTypes.INTEGER.UNSIGNED |
number |
BIGINT UNSIGNED |
DataTypes.BIGINT.UNSIGNED |
本项目用 string |
DATETIME(3) |
DataTypes.DATE(3) |
Date |
| 可空列 | allowNull: true |
T | null |
allowNull
email: {
type: DataTypes.STRING(254),
allowNull: true,
}
Sequelize 写入时会做一层校验;数据库列的 NULL/NOT NULL 才是所有写入路径的最终约束。
defaultValue
token_version: {
type: DataTypes.INTEGER.UNSIGNED,
allowNull: false,
defaultValue: 0,
}
Model 默认值与数据库 DEFAULT 0 最好一致。只有 Model 默认值时,绕过 Sequelize 的 SQL 写入得不到相同行为;只有数据库默认值时,实例创建前后的值表现可能不够直观。
时间默认值:
defaultValue: DataTypes.NOW
它表达 Sequelize 当前时间。数据库 DDL 中的 CURRENT_TIMESTAMP(3) 才是数据库端默认。不要把二者当成同一个执行位置。
primaryKey 与 autoIncrement
id: {
type: DataTypes.BIGINT.UNSIGNED,
primaryKey: true,
autoIncrement: true,
}
对应:
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY
TypeScript 还要使用 CreationOptional<string>,表达创建时不要求调用方提供 ID。
unique
username: {
type: DataTypes.STRING(50),
unique: true,
}
它描述唯一性,并在 Sequelize 建表时有用。但生产系统不能只依赖 Model 配置;真实数据库必须存在唯一索引:
UNIQUE KEY uk_admin_user_username (username)
“先查是否存在”也不能替代唯一索引,因为两个并发请求可能同时查到不存在。
validate
validate: {
isIn: [[0, 1, 2]],
}
Model validator 在 Sequelize 写入路径中提供更早错误,但:
- Raw SQL 可以绕过;
- 其他程序可以绕过;
- 某些批量操作可关闭校验;
- 它不是数据库 CHECK。
业务可读性和数据最终正确性通常需要不同层配合。
field
如果属性和旧数据库列名称不同,可使用(两边仍保持 snake_case):
display_name: {
type: DataTypes.STRING(100),
field: "legacy_display_name",
}
但本项目明确要求数据库及对应 Model 字段统一使用 snake_case,所以直接写:
display_name: {
type: DataTypes.STRING(100),
}
更直观,也避免双重命名映射。DTO 是否使用另一种命名是 API 层的独立决定。
3. 高频模型配置
sequelize
sequelize: db
把 Model 注册到当前唯一 Sequelize 实例。没有这项,Model 不知道使用哪个连接和方言。
tableName 与 freezeTableName
tableName: "admin_user",
freezeTableName: true,
显式绑定真实表名,并阻止 Sequelize 自动复数化。对于已有 SQL-first 表结构,这是清晰且可预测的选择。
underscored
underscored: true
它主要影响 Sequelize 自动生成的字段和外键命名,例如时间字段或关联外键。它不会把你手写的所有 camelCase 属性自动改造成项目约定,也不会修改已有表。
本项目直接把数据库映射属性写成 snake_case,减少认知转换。
timestamps
当前配置:
timestamps: false
原因是 created_at、updated_at 已显式定义,并由数据库 DDL 对 updated_at 使用:
DEFAULT CURRENT_TIMESTAMP(3)
ON UPDATE CURRENT_TIMESTAMP(3)
若使用 Sequelize 自动时间戳,可以配置:
timestamps: true,
underscored: true,
但不要同时让自动字段和手写字段互相冲突。选择一种明确策略,并让 Migration 与 Model 一致。
4. Scope:控制常用查询形状
当前默认 Scope:
defaultScope: {
attributes: { exclude: ["password_hash"] },
}
普通查询大致变为:
SELECT id, username, display_name, email, ...
FROM admin_user;
登录专用 Scope:
scopes: {
withPassword: {
attributes: { include: ["password_hash"] },
},
}
调用:
AdminUser.scope("withPassword").findOne({
where: { username },
});
Scope 的正确定位
- 它复用常见查询配置;
- 默认隐藏敏感列可降低误用概率;
- 它不负责决定调用者是否有权限;
- 它不能替代 DTO 白名单;
- 使用组合 Scope 时必须观察最后生成的字段列表。
特别要做一次实际 SQL 检查:确认 withPassword 真的把 password_hash 加回,而不是因 Scope 合并方式得到意外字段。
5. 索引应该在哪里定义
Sequelize Model 可以描述索引:
indexes: [
{
name: "idx_admin_user_status_created",
fields: ["account_status", "created_at"],
},
]
但在 SQL-first、版本化 Migration 流程中,真实索引应由 Migration 创建。Model 的声明可以作为映射文档,却不能代替数据库变更记录。
设计索引必须从查询出发。例如:
SELECT id, username, created_at
FROM admin_user
WHERE account_status = 1
ORDER BY created_at DESC
LIMIT 20;
再根据数据量、选择性和执行计划判断复合索引,而不是看到每个 where 字段就单独建索引。
6. init() 不等于运行 Migration
导入 models/admin_user.ts 会执行 AdminUser.init(),完成注册和映射,但不会直接执行:
CREATE TABLE admin_user (...);
只有主动调用 sync() 才可能按 Model 建表或改表。正式项目更推荐版本化 Migration,因为它能记录每次结构变化的顺序和意图。
因此常见流程是:
Migration 改真实表
+
Model 同步映射
+
集成测试确认二者一致
7. 日期字段的特殊注意
当前 updated_at 的 SQL 有自动更新:
updated_at DATETIME(3) NOT NULL
DEFAULT CURRENT_TIMESTAMP(3)
ON UPDATE CURRENT_TIMESTAMP(3)
Model 只声明了默认值:
defaultValue: DataTypes.NOW
这是可行的,因为真正的 ON UPDATE 行为存在数据库中。但只看 Model 的开发者可能不知道该行为。因此 DDL/Migration 才是数据库结构事实来源,Model 是应用映射。
8. 初学者常见错误
- 只写类属性,不调用
init(),运行时报 Model 未初始化。 - 只改
init(),认为生产表会自动变化。 - Model
defaultValue与数据库 DEFAULT 不一致。 - 使用
unique: true却没确认数据库真实索引。 - 把 Scope 当成授权或响应脱敏的唯一防线。
timestamps: true与显式时间字段混用,导致列名或更新时间行为异常。- 用
underscored: true后便随意混用 camelCase 和 snake_case。 - 为每个查询条件盲目建索引,不查看
EXPLAIN。
9. 动手练习
- 对照
admin_user.sql和AdminUser.init(),找出数据库独有的 CHECK、索引名和ON UPDATE行为。 - 写一条普通查询和一条
withPassword查询,比较实际 SELECT 字段。 - 假设新增
deleted_at DATETIME(3) NULL,列出 Migration、类属性和init()各要增加什么。 - 解释为什么当前项目不应仅通过
underscored: true来满足 snake_case 约定。 - 用 DBeaver 确认真实唯一索引名,而不是根据
unique: true猜测。
10. 复盘问题
Model.init()两个参数分别负责什么?allowNull、validate和数据库约束有什么区别?timestamps: false为什么适合当前admin_user?- Scope 能解决什么,不能解决什么?
- 为什么 Model 中写了索引仍不等于数据库已经存在索引?
下一章进入最高频部分:查询、创建、更新和删除,以及这些方法实际返回什么。