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. 常见误用

6. 动手练习

  1. 把每页 10 人改成 3 人,预测 offset 和 rows.length。
  2. 在 where 增加 last_name,预测是否会与 active 用 AND 连接。
  3. 去掉 Country 的 include,解释 DTO 为什么不能继续访问国家。
  4. 开 SQL 日志,确认 findAndCountAll() 的数据查询与计数查询。

下一章:WHERE、AND 与 OR。API 对照:Sequelize v6 findAll。