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 关联不是一回事

数据库外键负责:

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() 会根据 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. 初学者高频错误

  1. 只写 association,没建数据库外键。 ORM 外的写入仍可能产生孤儿数据。
  2. 只建外键,误以为 include 自动可用。 Sequelize 仍需要关联元数据。
  3. 两端字段类型不一致。 BIGINT UNSIGNED 与 INT 可能无法创建外键,或限制未来数据范围。
  4. 忘记一致的 as。 定义和查询别名必须完全相同。
  5. 把关联属性写成普通字段。 应使用 NonAttribute,否则类型推导会要求在 init 中配置它。
  6. 把关联属性写成必有。 未 include 时根本不会加载,应使用可选属性。
  7. 直接序列化完整关联模型。 应映射 DTO 并明确字段白名单。
  8. 依赖 sync() 创建约束。 生产结构应由版本化 Migration 管理。
  9. 在每个模型文件顶层互相执行关联注册。 CommonJS 循环依赖可能让某个模型尚未初始化。

9. 动手练习

  1. 只写出 admin_login_audit DDL,确认外键两端类型、索引顺序和删除策略。
  2. 定义 AdminLoginAudit,确保 id 与 admin_user_id 在 TypeScript 中都是字符串语义。
  3. 分别定义 hasMany 和 belongsTo,用 SQL 日志观察两种方向生成的 JOIN。
  4. 去掉查询中的 as,记录 Sequelize 的报错,再恢复。
  5. 不带 include 查询一条审计,比较 audit.admin;带 include 后再次比较。

10. 复盘问题