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 了谁、选择了哪些列”。

因此:

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,才进入另一种模型映射使用方式。

这些能力属于底层 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. 练习

  1. 只查客户主键,观察 TS 是否仍允许访问 email。
  2. 给查询增加 full_name,比较实例 get()、raw 和 DTO。
  3. 用 db.query 返回 address.city 别名,比较 nest 开关;解释为什么它不产生 rentals[]。
  4. 列表、详情和 reload 共用字段数组;故意拼错一列,看 satisfies 如何提醒。

下一章:include 参数实战。