12. 关联定义与连表查询基础
本章解决一个日常需求:管理员有多条登录审计记录,怎样用 Sequelize 和 TypeScript 表达 admin_user 1:N admin_login_audit。
关联不是“把两张表粘在一起”。数据库用外键保证数据关系;Sequelize 的 association 告诉 ORM 如何生成 JOIN 和组装对象;TypeScript 类型只负责静态检查。这三层都要写对。
1. 先从业务和 SQL 开始
假设登录审计表如下。这里使用 MySQL 8、InnoDB,并延续本项目的 snake_case:
CREATE TABLE admin_login_audit (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
admin_user_id BIGINT UNSIGNED NULL,
attempted_username VARCHAR(50) NOT NULL,
login_result TINYINT UNSIGNED NOT NULL,
ip_address VARCHAR(45) NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY (id),
KEY idx_admin_login_audit_admin_created (admin_user_id, created_at, id),
KEY idx_admin_login_audit_username_created (attempted_username, created_at, id),
CONSTRAINT chk_admin_login_audit_result
CHECK (login_result IN (1, 2, 3, 4)),
CONSTRAINT fk_admin_login_audit_admin
FOREIGN KEY (admin_user_id) REFERENCES admin_user (id)
ON UPDATE RESTRICT ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
关系是:
admin_user.id 1 ───────── N admin_login_audit.admin_user_id
外键两端的数据库类型必须兼容。当前 admin_user.id 是 BIGINT UNSIGNED,所以 admin_user_id 也不能写成有符号 BIGINT、INT 或字符串列。它允许 NULL,因为用户名可能不存在;删除管理员时 SET NULL 保留历史,而 attempted_username 保存尝试当时的快照。
2. 数据库外键与 Sequelize 关联不是一回事
数据库外键负责:
- 拒绝不存在的
admin_user_id; - 按
ON DELETE、ON UPDATE规则维护引用完整性; - 无论数据由 Node.js、DBeaver 还是其他程序写入,都生效。
Sequelize 关联负责:
- 提供
include连表查询; - 根据关联别名生成关联方法;本章的
as: "login_audits"会生成类似admin.getLogin_audits()的运行时方法; - 告诉 Sequelize 用哪个字段、别名连接模型。
只写以下代码,并不会保证数据库中真的存在外键:
AdminLoginAudit.belongsTo(AdminUser, {
foreignKey: "admin_user_id",
as: "admin",
});
反过来,只在数据库创建外键而不定义 association,数据库约束依然有效,但 Sequelize 不知道怎样使用 include: { association: "admin" }。
3. 用 TypeScript 定义审计模型
import {
CreationOptional,
DataTypes,
ForeignKey,
InferAttributes,
InferCreationAttributes,
Model,
NonAttribute,
} from "sequelize";
import db from "../db";
import AdminUser from "./admin_user";
export class AdminLoginAudit extends Model<
InferAttributes<AdminLoginAudit>,
InferCreationAttributes<AdminLoginAudit>
> {
declare id: CreationOptional<string>;
declare admin_user_id: ForeignKey<AdminUser["id"]> | null;
declare attempted_username: string;
declare login_result: number;
declare ip_address: string | null;
declare created_at: CreationOptional<Date>;
// 这不是 admin_login_audit 的列,而是 include 后由 Sequelize 附加的关联对象。
declare admin?: NonAttribute<AdminUser | null>;
}
AdminLoginAudit.init(
{
id: {
type: DataTypes.BIGINT.UNSIGNED,
primaryKey: true,
autoIncrement: true,
},
// admin_user_id 已用 ForeignKey 标记,InferCreationAttributes 能识别它的关联语义。
admin_user_id: {
type: DataTypes.BIGINT.UNSIGNED,
allowNull: true,
},
attempted_username: {
type: DataTypes.STRING(50),
allowNull: false,
},
login_result: {
type: DataTypes.TINYINT.UNSIGNED,
allowNull: false,
},
ip_address: {
type: DataTypes.STRING(45),
allowNull: true,
},
created_at: {
type: DataTypes.DATE(3),
allowNull: false,
},
},
{
sequelize: db,
tableName: "admin_login_audit",
freezeTableName: true,
timestamps: false,
},
);
这里故意没有给 created_at 配置 defaultValue: DataTypes.NOW,让 DDL 中的 DEFAULT CURRENT_TIMESTAMP(3) 成为真正的默认值来源。MySQL 不支持像 PostgreSQL 那样普遍使用 RETURNING 返回数据库默认值;创建后如果后续逻辑立刻需要准确的 created_at,应 reload() 再读取。
ForeignKey<T> 的意义
declare admin_user_id: ForeignKey<AdminUser["id"]> | null;
AdminUser["id"] 是索引访问类型,得到当前模型中 id 的 TypeScript 类型,也就是 string。这样以后管理员 ID 类型调整时,外键类型能一起受到检查。
ForeignKey<T> 是 Sequelize 提供的品牌类型,用来告诉类型推导:“这是一个由关联管理的外键”。它不会创建数据库外键,也不会在 JavaScript 运行时验证值。
NonAttribute<T> 的意义
declare admin?: NonAttribute<AdminUser | null>;
admin 是 JOIN 后才可能附加的对象,不是当前表的列。NonAttribute<T> 让 InferAttributes 不把它当作 Model.init() 必须配置的字段。这里保留 null,因为可空外键和 LEFT JOIN 都可能得到“已加载但没有关联管理员”的结果。
问号非常重要:普通查询没有 include 时,audit.admin 是 undefined。它和关联为空的 null 不同:
undefined:这次没有加载关联
null:执行了 LEFT JOIN,但用户名未知或管理员已经删除
因此 DTO mapper 不应把 undefined 和 null 不加区分地混为一谈:前者可能是代码忘记 include,后者是有效业务状态。
4. 在两个模型上定义关联
在 AdminUser 类中增加关联属性时,可以写:
import type { NonAttribute } from "sequelize";
import type { HasManyGetAssociationsMixin } from "sequelize";
import type { AdminLoginAudit } from "./admin_login_audit";
// 类内部:
declare login_audits?: NonAttribute<AdminLoginAudit[]>;
declare getLogin_audits: HasManyGetAssociationsMixin<AdminLoginAudit>;
然后集中注册关联:
AdminUser.hasMany(AdminLoginAudit, {
foreignKey: "admin_user_id",
as: "login_audits",
onUpdate: "RESTRICT",
onDelete: "SET NULL",
});
AdminLoginAudit.belongsTo(AdminUser, {
foreignKey: "admin_user_id",
as: "admin",
onUpdate: "RESTRICT",
onDelete: "SET NULL",
});
为什么两边都写?
hasMany支持从管理员查询审计列表;belongsTo支持从审计记录查询所属管理员;- 两个方向的查询需求不同,Sequelize 不会因为定义一边就自动补齐另一边。
hasMany() 会根据 as 在运行时安装 mixin 方法,但不会把 snake_case 自动转换成 camelCase。本章的别名会得到 getLogin_audits()。TypeScript 也不会自动把这个运行时方法写进类声明;需要调用时,必须像上面一样用对应的 Mixin 类型显式声明。实际项目若选择 camelCase 关联别名,访问器名称会随别名改变,但数据库列仍应保持 snake_case。
推荐集中初始化关联
模型相互 import 容易造成 CommonJS 循环依赖。更稳妥的结构是:先加载全部模型,再在一个函数中注册关联。
export function initializeModelAssociations(): void {
AdminUser.hasMany(AdminLoginAudit, {
foreignKey: "admin_user_id",
as: "login_audits",
onUpdate: "RESTRICT",
onDelete: "SET NULL",
});
AdminLoginAudit.belongsTo(AdminUser, {
foreignKey: "admin_user_id",
as: "admin",
onUpdate: "RESTRICT",
onDelete: "SET NULL",
});
}
应用启动时只调用一次。测试进程重复注册关联可能出现别名冲突,因此初始化位置要固定。
5. as 是关联的身份,不只是显示名称
定义时用了:
as: "login_audits"
查询时必须使用相同别名:
const admin = await AdminUser.findByPk(admin_id, {
include: [{ association: "login_audits" }],
});
或显式模型形式:
include: [{ model: AdminLoginAudit, as: "login_audits" }]
如果查询写成 as: "audits",Sequelize 会报找不到匹配关联。一个模型可能对同一张表建立多个关联,例如 created_by 和 updated_by,所以别名必须精确。
6. 基础 JOIN:从 SQL 看 include
查询一条审计记录及管理员:
const audit = await AdminLoginAudit.findByPk(audit_id, {
include: [
{
model: AdminUser,
as: "admin",
attributes: ["id", "username", "display_name"],
},
],
});
近似 SQL:
SELECT
audit.id,
audit.admin_user_id,
audit.attempted_username,
audit.login_result,
audit.created_at,
admin.id AS `admin.id`,
admin.username AS `admin.username`,
admin.display_name AS `admin.display_name`
FROM admin_login_audit AS audit
LEFT OUTER JOIN admin_user AS admin
ON audit.admin_user_id = admin.id
WHERE audit.id = ?;
Sequelize 会把扁平 SQL 结果组装为嵌套对象。attributes 是数据最小化的重要手段,尤其不能因为 include 而意外选出 password_hash。
7. 常见关联类型怎么选
| 业务关系 | Sequelize | 外键通常放在哪里 |
|---|---|---|
| 一个管理员有多条审计 | hasMany |
审计表 |
| 一条审计属于一个管理员 | belongsTo |
审计表 |
| 管理员有一个设置对象 | hasOne |
设置表 |
| 管理员有多个权限,权限属于多个管理员 | belongsToMany |
中间表 |
多对多不要把 ID 数组塞进 VARCHAR/JSON 来代替中间表。规范做法是创建例如 admin_user_role(admin_user_id, role_id),并为组合键建立唯一约束。
8. 初学者高频错误
- 只写 association,没建数据库外键。 ORM 外的写入仍可能产生孤儿数据。
- 只建外键,误以为
include自动可用。 Sequelize 仍需要关联元数据。 - 两端字段类型不一致。
BIGINT UNSIGNED与INT可能无法创建外键,或限制未来数据范围。 - 忘记一致的
as。 定义和查询别名必须完全相同。 - 把关联属性写成普通字段。 应使用
NonAttribute,否则类型推导会要求在init中配置它。 - 把关联属性写成必有。 未 include 时根本不会加载,应使用可选属性。
- 直接序列化完整关联模型。 应映射 DTO 并明确字段白名单。
- 依赖
sync()创建约束。 生产结构应由版本化 Migration 管理。 - 在每个模型文件顶层互相执行关联注册。 CommonJS 循环依赖可能让某个模型尚未初始化。
9. 动手练习
- 只写出
admin_login_auditDDL,确认外键两端类型、索引顺序和删除策略。 - 定义
AdminLoginAudit,确保id与admin_user_id在 TypeScript 中都是字符串语义。 - 分别定义
hasMany和belongsTo,用 SQL 日志观察两种方向生成的 JOIN。 - 去掉查询中的
as,记录 Sequelize 的报错,再恢复。 - 不带 include 查询一条审计,比较
audit.admin;带 include 后再次比较。
10. 复盘问题
- 数据库外键、Sequelize association、TypeScript 关联属性各负责什么?
- 为什么
admin_user_id使用ForeignKey<AdminUser["id"]>? - 为什么
admin使用NonAttribute并且通常是可选属性? belongsTo与hasMany的外键都写同一个字段,为什么仍要定义两次?- 定义关联时的
as与查询时不一致会怎样? - 为什么 include 时仍要写
attributes白名单?