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 写入路径中提供更早错误,但:

业务可读性和数据最终正确性通常需要不同层配合。

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 的正确定位

特别要做一次实际 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. 初学者常见错误

  1. 只写类属性,不调用 init(),运行时报 Model 未初始化。
  2. 只改 init(),认为生产表会自动变化。
  3. Model defaultValue 与数据库 DEFAULT 不一致。
  4. 使用 unique: true 却没确认数据库真实索引。
  5. 把 Scope 当成授权或响应脱敏的唯一防线。
  6. timestamps: true 与显式时间字段混用,导致列名或更新时间行为异常。
  7. 用 underscored: true 后便随意混用 camelCase 和 snake_case。
  8. 为每个查询条件盲目建索引,不查看 EXPLAIN。

9. 动手练习

  1. 对照 admin_user.sql 和 AdminUser.init(),找出数据库独有的 CHECK、索引名和 ON UPDATE 行为。
  2. 写一条普通查询和一条 withPassword 查询,比较实际 SELECT 字段。
  3. 假设新增 deleted_at DATETIME(3) NULL,列出 Migration、类属性和 init() 各要增加什么。
  4. 解释为什么当前项目不应仅通过 underscored: true 来满足 snake_case 约定。
  5. 用 DBeaver 确认真实唯一索引名,而不是根据 unique: true 猜测。

10. 复盘问题

  1. Model.init() 两个参数分别负责什么?
  2. allowNull、validate 和数据库约束有什么区别?
  3. timestamps: false 为什么适合当前 admin_user?
  4. Scope 能解决什么,不能解决什么?
  5. 为什么 Model 中写了索引仍不等于数据库已经存在索引?

下一章进入最高频部分:查询、创建、更新和删除,以及这些方法实际返回什么。