21 attributes 与结果形状:类型不能替你选择字段
对应项目:customerListAttributes 与 DTO。
1. 为什么 Model、attributes 和 interface 都有字段?
它们分别回答三个问题:
| 定义 | 工作发生在哪一层? | 含义 |
|---|---|---|
Model 属性与 init() |
TS 契约与 ORM 映射 | 表里有哪些字段、如何读写 |
attributes |
运行时查询 | 这次 SELECT 哪些列 |
| DTO interface 与 mapper | API 契约与转换 | 对外返回哪些字段、如何表示 |
interface 编译后消失。只定义 CustomerListItemDto 不会减少 SELECT 列;只写 attributes 也不会生成 DTO。
当前 active 在 Model 中是数字,在 DTO 中是 boolean;时间在 Model 中是 Date,在 DTO 中是 ISO 字符串;country 被 mapper 整理进地址对象。这些差异正是单独定义 DTO 的理由。
2. 最实用的复用:共享字段数组,而非共享所有类型
import { type Attributes } from "sequelize";
import Customer from "../../models/customer";
const customer_list_attributes = [
"customer_id", "store_id", "first_name", "last_name",
"email", "active", "create_date",
] satisfies Array<keyof Attributes<Customer>>;
const rows = await Customer.findAll({
attributes: customer_list_attributes,
limit: 20,
});
等价 SQL:
SELECT customer_id, store_id, first_name, last_name,
email, active, create_date
FROM customer
LIMIT 20;
列表、详情基础资料、创建后的 reload 共用数组,减少字段漂移。satisfies 检查拼写,不改变运行时数据,也不验证表结构。
不要为了减少七八个字段的重复就建立通用 DTO 生成框架。字段数组留在 Customer Service 附近即可;真正被多个模块使用时再拆共享文件。
3. 别名、include 和 exclude
列别名
const rows = await Customer.findAll({
attributes: ["customer_id", ["first_name", "given_name"]],
raw: true,
limit: 5,
});
SELECT customer_id, first_name AS given_name FROM customer LIMIT 5;
given_name 是查询结果的键,不是新增表字段。别名必须由代码定义,不能随意接收客户端字符串。
增加计算字段
import { fn, col } from "sequelize";
const rows = await Customer.findAll({
attributes: {
include: [[fn("CONCAT", col("first_name"), " ", col("last_name")), "full_name"]],
},
limit: 5,
});
相当于查询默认列再加 CONCAT(first_name, ' ', last_name) AS full_name。这里的 attributes.include 和关联 include 完全不是同一件事。
排除字段
{ attributes: { exclude: ["last_update"] } }
是“默认字段减去这些字段”,不是最小白名单。未来新增敏感列时可能自动被选出来。对 API 返回数据,显式字段白名单与 DTO 更可控;密码等字段还需要 Scope 和日志保护。
4. 部分列查询不会自动缩窄 Model 类型
const customer = await Customer.findByPk(1, {
attributes: ["customer_id"],
});
// 编辑器可能允许访问 first_name,但本次没有 SELECT 它。
// 不应依赖:customer?.first_name.toLowerCase()
Model 类型描述模型整体,不是每次查询的精确投影。Sequelize v6 的类型系统不能完全推导“本次 include 了谁、选择了哪些列”。
因此:
- 查询和 mapper 靠近,明确 mapper 需要的字段;
- 不把部分列实例当作完整详情到处传递;
as Pick<...>只是断言,不会补出列;- 必要时在读取边界校验字段,再返回独立投影/DTO 类型。
SQL 中有 JOIN 时,ORM 为去重、关联装配可能自动补主键。字段白名单是你的意图,实际 SELECT 仍需看日志。
5. 四种结果:不要混在一起
Model 实例
默认查询返回 Customer 实例,有 update()、reload()、get() 等方法。关联也通常是实例。
普通对象:get / toJSON
const plain_customer = customer.get({ plain: true });
const json_customer = customer.toJSON();
它们把实例整理成普通对象,但不等于 JSON 字符串,不保证 Date 已变成 string,也不自动删除敏感字段。JSON.stringify() 序列化时 Date 才会使用 ISO 表示。
raw 行
const rows = await Customer.findAll({
attributes: ["customer_id", "first_name"],
raw: true,
});
通常得到普通行,不可调用 rows[0].reload()。v6 的 TS 声明未必精确反映 raw 投影,不能因为编辑器认为是 Model 就信它。
一对多 JOIN 的 raw 结果可能是“同一客户多行”;不再由 Model 装配阶段合并。nest 也不会恢复这种去重与数组聚合。
DTO
return {
customer_id: customer.customer_id,
active: customer.active === 1,
create_date: customer.create_date.toISOString(),
};
DTO 是你主动定义的 API 边界;最适合本项目对状态、日期、金额和嵌套地址做明确处理。
6. db.query 的 raw、nest、plain
import { QueryTypes } from "sequelize";
import db from "../../db";
interface CustomerNameRow {
customer_id: number;
first_name: string;
}
const rows = await db.query<CustomerNameRow>(
"SELECT customer_id, first_name FROM customer WHERE store_id = :store_id",
{ type: QueryTypes.SELECT, replacements: { store_id: 1 } },
);
这里直接得到行数组,泛型描述一行。不需要写 CustomerNameRow[],也不必为了普通对象再加 raw: true。若提供 model/mapToModel,才进入另一种模型映射使用方式。
plain: true:SELECT 返回首行或null;仍建议 SQL 加LIMIT 1,不要以为它会自动限流。nest: true:把address.city这样的点号列别名变成嵌套对象。raw: true:在涉及 Model 映射时跳过实例构建;不是“原始 SQL 才安全”的开关。
这些能力属于底层 query 选项。不要用 Customer.findAll({ plain: true }) 表达查一条:部分选项虽然会透传,但方法名和 TS 返回契约会变得不清楚,直接用 findOne()。
get({ plain: true }) 的 plain 是实例转换选项;db.query({ plain: true }) 的 plain 是首行返回选项。名字相同,语义不同。
7. 计算别名为何不能直接 row.full_name?
查询添加别名,不会在模型类上生成 TS 声明和属性访问器。使用实例时可通过 get() 读取并检查:
const value: unknown = row.get("full_name");
if (typeof value !== "string") {
throw new Error("full_name 查询结果不符合预期");
}
不要因此把所有统计列都加进 Customer Model。统计类型属于具体查询,可用独立 Raw SQL 行类型,见 25、28。
8. 练习
- 只查客户主键,观察 TS 是否仍允许访问 email。
- 给查询增加 full_name,比较实例
get()、raw 和 DTO。 - 用
db.query返回address.city别名,比较 nest 开关;解释为什么它不产生rentals[]。 - 列表、详情和 reload 共用字段数组;故意拼错一列,看
satisfies如何提醒。
下一章:include 参数实战。