19 从一个客户列表理解查询 options
本章开始的 19~30 章是 Actor / Customer 练习的实务补充,基于项目当前使用的 Sequelize 6.37.8、mysql2 和 MySQL 8。不要把 v7 文档中的写法直接搬到 v6。
阅读入口:系列目录。对应代码:findCustomers()。
1. options 不是“执行步骤”,而是查询说明书
需求:查询门店 1 的启用客户,按姓氏排序,返回第 2 页,每页 10 人,同时带上地址、城市和国家。
把需求拆成五个问题:
| 问题 | 常用参数 | SQL 对应部分 |
|---|---|---|
| 返回哪些列? | attributes |
SELECT |
| 哪些记录符合条件? | where |
WHERE |
| 还需要哪些关联? | include |
JOIN,或额外查询 |
| 按什么顺序返回? | order |
ORDER BY |
| 返回哪一段? | limit、offset |
LIMIT、OFFSET |
import Customer from "../../models/customer";
import Address from "../../models/address";
import City from "../../models/city";
import Country from "../../models/country";
// 以下示例路径按“从本章所在目录引用源码”书写。
// 在实际 Service 中沿用该文件自己的 import 路径。
const result = await Customer.findAndCountAll({
attributes: [
"customer_id", "store_id", "first_name", "last_name", "active",
],
where: { store_id: 1, active: 1 },
include: [{
model: Address,
as: "address",
attributes: ["address_id", "address", "city_id"],
required: false,
include: [{
model: City,
as: "city",
attributes: ["city_id", "city", "country_id"],
include: [{
model: Country,
as: "country",
attributes: ["country_id", "country"],
}],
}],
}],
order: [["last_name", "ASC"], ["customer_id", "ASC"]],
limit: 10,
offset: 10,
});
前提:应用启动时已调用 initializeModelAssociations()。导入 Model 不等于自动注册了这些关联。
便于阅读的等价数据查询 SQL:
SELECT c.customer_id, c.store_id, c.first_name, c.last_name, c.active,
a.address_id, a.address, a.city_id,
ci.city, ci.country_id, co.country
FROM customer AS c
LEFT JOIN address AS a ON a.address_id = c.address_id
LEFT JOIN city AS ci ON ci.city_id = a.city_id
LEFT JOIN country AS co ON co.country_id = ci.country_id
WHERE c.store_id = 1 AND c.active = 1
ORDER BY c.last_name ASC, c.customer_id ASC
LIMIT 10 OFFSET 10;
结果中 result.rows 是客户实例数组,每个实例可以带 address.city.country;result.count 是符合筛选条件的客户总数,不是当前页长度。
本系列标注“等价 SQL”的地方用于解释语义,不承诺与日志逐字一致。实际 SQL 可能额外选择主键、使用 address->city 别名、添加子查询,或拆成多条。以本项目版本输出的日志为准。
SQL 示例中的 :customer_id 等是供 Sequelize replacements 处理的占位符,不能原样粘进 MySQL CLI。手工验证时换成具体值,或使用 MySQL 会话变量:
SET @customer_id = 1;
SELECT customer_id, first_name FROM customer WHERE customer_id = @customer_id;
SQL 的 ... 代表省略部分字段/数据,须先补全;两会话锁实验则给出了可在独立练习库执行的具体 SQL。
2. 为什么调整对象属性顺序没用?
下面两种写法的筛选含义一样:
{ where: { active: 1 }, limit: 10 }
{ limit: 10, where: { active: 1 } }
不是“先拿 10 条,再筛选 active”。对象键的排列不定义 SQL 处理顺序。
理解 SQL 的逻辑顺序即可:
FROM / JOIN → WHERE → GROUP BY → HAVING → SELECT → ORDER BY → LIMIT
这是学习用的逻辑模型,不是数据库物理执行计划。MySQL 优化器可能调整连接顺序、下推条件或走索引。
3. 应该选哪个查询方法?
| 方法 | 默认返回 | 适合场景 |
|---|---|---|
findByPk(id, options) |
实例或 null |
已知客户主键,查详情 |
findOne(options) |
实例或 null |
找一条满足业务条件的记录 |
findAll(options) |
实例数组,可能 [] |
列表、导出、关联列表 |
findAndCountAll(options) |
{ rows, count } |
需要总数的分页 |
count(options) |
通常为 number |
只查数量,不加载记录 |
findOne() 不等于唯一约束:多个记录满足条件时只返回一条。想获取“最新一条”,必须加 order;想保证邮箱唯一,要靠数据库唯一约束,而不是 findOne()。
没有 group 时 findAndCountAll().count 通常是数字;有 group 时变成分组计数数组,见 23 章。
4. 次高频参数:先知道用途,再决定是否使用
transaction 与 lock
transaction 指定这条查询参加哪个事务;lock 指定锁定读取。两者不是同义词。只传 transaction 不会自动让普通 SELECT 变成 FOR UPDATE。详见 26、27。
raw
raw: true 跳过 Model 实例包装,通常返回普通行对象,但不会帮你设计 DTO,也不会把一对多 JOIN 行自动整理成客户数组。见 21 章。
logging 与 benchmark
await Customer.findAll({
attributes: ["customer_id"],
limit: 5,
benchmark: true,
logging: (sql, elapsed_ms) => {
console.debug({ sql, elapsed_ms });
},
});
用于开发诊断:到底发了几条 SQL、用了什么字段、花了多久。logging: false 可以关闭单次查询的 SQL 输出,但不等于关闭整个项目的错误日志。
SQL 日志可能包含姓名、邮箱、条件值甚至认证信息。示例仅用于本地诊断;生产环境应脱敏、控制采样和访问权限。该耗时也不等于整个 Controller 的端到端耗时。
rejectOnEmpty
const customer = await Customer.findByPk(customer_id, {
rejectOnEmpty: true,
});
没有记录时抛出 Sequelize 的空结果错误,而不返回 null。它不会自动变成项目的 CUSTOMER_NOT_FOUND。本项目用显式判空后抛 AppError,对初学者更直观。
paranoid
仅当 Model 已配置软删除(paranoid: true,以及相应时间戳/删除字段)时才有意义:查询的 paranoid: false 可包含已软删除记录。
当前 Customer 没有启用软删除。加查询参数不会凭空出现 deleted_at,也不会把 active 当成软删除字段。
5. 常见误用
type: QueryTypes.SELECT主要用于db.query();不要以为给findAll()加它就获得新的返回类型。attributes少写一列,不代表 TypeScript 会自动知道这一列未加载。include字段齐全,不代表 DTO 映射的字段名一定写对。- 全局 Scope、软删除、关联 Scope、Hook 可能追加条件;不能只看眼前 options 判断完整 SQL。
- options 类型很宽,能通过编译只是第一步,仍需要确认 SQL 和返回值。
6. 动手练习
- 把每页 10 人改成 3 人,预测
offset和rows.length。 - 在
where增加last_name,预测是否会与active用 AND 连接。 - 去掉 Country 的
include,解释 DTO 为什么不能继续访问国家。 - 开 SQL 日志,确认
findAndCountAll()的数据查询与计数查询。
下一章:WHERE、AND 与 OR。API 对照:Sequelize v6 findAll。