28 答疑:模型类型、关联字段与 DTO
本章对应这次“attributes 太长、include 后类型难处理、是不是必须建完所有 Model”的疑问。源码:Customer Model、Customer Service、Actor Model。
1. attributes 能不能直接从 interface 生成?
不能在运行时直接枚举 TS interface,因为它编译后消失。
当前方案是共享字段数组并用 satisfies Array<keyof Attributes<Customer>> 检查拼写,列表、详情和 reload 复用。见 21 章。
能否把 Model 属性类型用 Pick 复用到 DTO?可以用于完全相同的字段:
type CustomerName = Pick<Attributes<Customer>,
"customer_id" | "first_name" | "last_name"
>;
但 Customer 的 active 是 number,DTO 是 boolean,Date 要变 string,关联要重组,不能机械地 Pick 整个 Model 当 API 契约。
对新人更重要的不是“每个字段只写一次”,而是明确哪次查询加载了什么、对外承诺什么。当前七八个字段的显式 DTO 并不属于需要消除的坏重复。
2. findAndCountAll 为什么要求关联模型?
findAndCountAll() 本身不要求关联模型:没有 include 时只需要 Customer。
当你希望它自动生成 Customer → Address → City → Country 查询并装配嵌套实例时,ORM 需要知道:外键、关联方向、别名、各表字段和类型。Model 与 Association 提供这些元数据。
TS 中的 declare address?: NonAttribute<Address> 则告诉编辑器“实例可能挂有 address”。这不是数据库字段,也不是 ORM 自动 JOIN 的命令。
三件事分开看:
数据库 FK:约束记录引用有效
Sequelize Association:描述如何查询和装配关系
TS declare:描述程序可能读取的属性
只声明类型不注册 Association,include 会失败;只注册 Association 不声明类型,运行时可能有值,但 TS 不知道。
3. NonAttribute 与 CreationOptional 各管什么?
class Customer extends Model<
InferAttributes<Customer>,
InferCreationAttributes<Customer>
> {
declare customer_id: CreationOptional<number>;
declare first_name: string;
declare email: string | null;
declare address?: NonAttribute<Address>;
}
这是类型片段;实际模型还需其他字段及 init()。
CreationOptional<number>:这是表字段,但创建时可由数据库/默认机制提供,例如自增 ID。它不会自己创建 SQL 默认值。NonAttribute<Address>:这不是表字段,让 InferAttributes 不把它推断成 customer 表的一列。address?:本次实例可能没加载关联。email: string | null:数据库允许 NULL,是字段值可能为空,不是“没加载关联”。Sequelize 的创建参数对 nullable 字段还有可省略处理,不能仅凭阅读属性声明判断最终 create 类型。
把 address 写成 CreationOptional 会混淆“创建时省略字段”与“关联查询结果”,甚至让 init 类型要求出现不该有的字段。
SQL 的 NULL、TS 的可选属性、Model 创建默认值和查询未选择字段,分别属于不同问题。
4. 有外键,为什么 address 仍可能是 undefined?
const customer = await Customer.findByPk(customer_id);
只查了 Customer,address_id 有值,address 未加载。外键不会替你加载数据。
即使写了 include,LEFT JOIN + 子表筛选 也可能让关联没有匹配值。单值关联可能是 null;多值关联可以是空数组。某些 Model 需要 NonAttribute<Address | null> 明确表达这个状态。
本项目完整地址 DTO 的约定是:必须加载 address/city/country。只要查询不满足这个约定,就不适合调用该 mapper。
5. 每访问一个字段就判空抛错,是不是很傻?
判空本身不是问题;在每层到处重复判空,才让代码难读。
当前将“完整关联”检查集中到一个边界:
type LoadedCity = City & { country: Country };
type LoadedAddress = Address & { city: LoadedCity };
type LoadedCustomer = Customer & { address: LoadedAddress };
function assertCustomerLoaded(
customer: Customer,
): asserts customer is LoadedCustomer {
if (!customer.address?.city?.country) {
throw new Error("Customer 查询缺少完整地址关联");
}
}
function toCustomerDto(customer: Customer) {
assertCustomerLoaded(customer);
// 此处 TS 已知道关联路径存在。
return {
customer_id: customer.customer_id,
country: customer.address.city.country.country,
};
}
这是小型“查询契约检查”:mapper 要完整地址,就要求查询满足它。缺少 include 是开发错误,通常应作为内部 Error 记录,而不是告诉用户“请输入国家”。
但这个 assertion 只检查关联对象存在,没有全面验证所有选择列、真实 DDL 或 Date 类型。若投影可能改变,还要测试 mapper 或加必要的字段检查,不能认为一次 asserts 验证了一切。
6. asserts 与 as 的本质区别
const loaded = customer as LoadedCustomer;
只是告诉 TS“相信我”,没有任何运行时检查。
asserts 也是开发者给 TS 的承诺,但函数内部可以真实执行判断。因此它更适合作为已知查询契约的边界。不过写错检查逻辑仍可能撒谎;测试是必需的。
不要为了消除红线使用 !、as unknown as ... 或 any 隐藏缺失 include。
如果关联是业务上合理的可缺失数据,则不要硬抛错,应将 DTO 类型定义成 address: ... | null 或相应可选形态。先分清“未加载”“筛选后不存在”和“业务确实允许没有”。
7. 可以让查询函数返回 LoadedCustomer 吗?
可以,把加载与断言封装在同一个小函数中:
async function findCustomerWithAddress(
customer_id: number,
): Promise<LoadedCustomer | null> {
const customer = await Customer.findByPk(customer_id, {
attributes: customer_list_attributes,
include: customer_address_include,
});
if (customer === null) return null;
assertCustomerLoaded(customer);
return customer;
}
字段数组、include、断言来自同一模块。需要事务时再明确增加 Transaction 参数或受控 options;不要原样透传任意 FindOptions,让调用方覆盖 include 后仍声称返回完整关联。
本项目当前 mapper 内部断言也合理,足够简单。不要为学习案例过早建立庞大的 Repository 泛型体系。
8. 关联方法为什么用官方 Mixin 类型?
import type Film from "../../models/film";
import type {
BelongsToManyGetAssociationsMixin,
BelongsToManySetAssociationsMixin,
} from "sequelize";
// 放在 Actor 类中:
declare films?: NonAttribute<Film[]>;
declare getFilms: BelongsToManyGetAssociationsMixin<Film>;
declare setFilms: BelongsToManySetAssociationsMixin<Film, number>;
手写 setFilms: (ids: number[]) => Promise<void> 可能遗漏实例输入、transaction 等 options。官方 Mixin 更完整;declare 只补类型,方法实际由 Association 注册产生。
import type 编译后消失,能减少模型相互 import 导致的运行时循环依赖;真正的关联注册仍用普通 import,并集中在 associations.ts。它不是自动解决所有循环依赖的开关。
setFilms() 是便利方法,不意味着“检查电影、替换全部关系、并发控制”自动满足本项目所有业务规则。
9. 没建 Payment/Inventory Model,还能写客户详情吗?
可以。当前最近租赁使用 rental/inventory/film 的 Raw JOIN,统计直接查询 payment/rental。
interface RentalRow {
rental_id: number;
film_title: string;
rental_date: Date;
}
const rows = await db.query<RentalRow>(sql, {
type: QueryTypes.SELECT,
replacements: { customer_id },
});
泛型写“一行”,结果是 RentalRow[];它只是静态声明,不解析 SQL,不核对 alias,也不验证 mysql2 返回 Date。
SQL 如果没 SELECT film_title,TS 仍可能允许读取它。对重要查询需要真实数据库结果测试。字段类型难以信任的边界还可以使用 unknown + 显式验证。
为高频 CRUD/关联建立 Model 有价值;只为了让一条统计 SQL 看上去全部 ORM 化而赶建十张 Model,不一定更好。
10. 学习建议
先画四层字段对照:DDL → Model → 本次查询投影 → DTO。每次新增字段,分别判断四层是否需要修改;每次新增 include,先确定关联是单对象还是数组,以及缺失是否正常。
下一章:await、reload 与事务可见性。参考:v6 TypeScript。