20 WHERE、AND 与 OR:先写括号,再写对象

对应项目:findCustomers()、WhereBuilder。下面示例使用 Op、WhereOptions、Attributes 和 Customer:

import { Op, type Attributes, type WhereOptions } from "sequelize";
import Customer from "../../models/customer";

1. 普通对象默认就是 AND

需求:门店 1 的启用客户。

const where: WhereOptions<Attributes<Customer>> = {
  store_id: 1,
  active: 1,
};
WHERE store_id = 1 AND active = 1

不是 OR,也不是只保留最后一个字段。对象中的不同字段通常隐式 AND。

同字段的范围也用 AND:

{ customer_id: { [Op.gte]: 100, [Op.lt]: 200 } }
WHERE customer_id >= 100 AND customer_id < 200

不要重复写 customer_id 对象键。JavaScript 会覆盖,根本到不了 SQL。

2. 跨字段 OR:数组中的每个对象是一个分支

需求:名字或姓氏包含 ANN。

const name_condition = {
  [Op.or]: [
    { first_name: { [Op.like]: "%ANN%" } },
    { last_name: { [Op.like]: "%ANN%" } },
  ],
};
WHERE first_name LIKE '%ANN%' OR last_name LIKE '%ANN%'

注意一个分支也可以是 AND:

{
  [Op.or]: [
    { first_name: "ANN", active: 1 },
    { last_name: "LEE" },
  ],
}
WHERE (first_name = 'ANN' AND active = 1) OR last_name = 'LEE'

第一分支中的 active 不会自动约束第二分支。

3. 最常见的错误:把 AND 放进 OR 的一条分支

正确需求:启用 AND(名字匹配 OR 姓氏匹配)。

const where: WhereOptions<Attributes<Customer>> = {
  active: 1,
  [Op.or]: [
    { first_name: { [Op.like]: "%ANN%" } },
    { last_name: { [Op.like]: "%ANN%" } },
  ],
};
WHERE active = 1
  AND (first_name LIKE '%ANN%' OR last_name LIKE '%ANN%')

错误写法:

{
  [Op.or]: [
    { active: 1, first_name: { [Op.like]: "%ANN%" } },
    { last_name: { [Op.like]: "%ANN%" } },
  ],
}
WHERE (active = 1 AND first_name LIKE '%ANN%')
   OR last_name LIKE '%ANN%'

一个停用客户如果姓氏匹配,也会被错误返回。先用三条假数据检查逻辑:

active 名字匹配 姓氏匹配 正确结果 错误结果
1 是 否 返回 返回
0 是 否 不返回 不返回
0 否 是 不返回 返回

SQL 中 AND 优先于 OR,但不要依赖读者记优先级。把业务括号明确写出来。

4. 两组 OR 需要怎样组合?

需求:(门店1 OR 门店2)AND(启用 OR 没有邮箱)。

const where: WhereOptions<Attributes<Customer>> = {
  [Op.and]: [
    { [Op.or]: [{ store_id: 1 }, { store_id: 2 }] },
    { [Op.or]: [{ active: 1 }, { email: null }] },
  ],
};

本项目版本生成的条件是:

WHERE ((store_id = 1 OR store_id = 2)
   AND (active = 1 OR email IS NULL))

不能在同一个对象里写两次 [Op.or]:它们是同一个 Symbol 键,后者覆盖前者。

同样,不要用展开操作盲目拼两个具有相同 Symbol 键的条件。多个片段优先放进 [Op.and]: [片段1, 片段2]。

5. 高频操作符如何选择?

Sequelize 条件 等价 SQL 注意点
{ active: { [Op.eq]: 1 } } active = 1 常可简写 { active: 1 }
{ store_id: { [Op.ne]: 1 } } store_id <> 1 NULL 不自动满足“不等于”
{ customer_id: { [Op.gt]: 10 } } customer_id > 10 gte 包含边界
{ customer_id: { [Op.lte]: 20 } } customer_id <= 20 lt 不包含边界
{ customer_id: { [Op.between]: [10, 20] } } BETWEEN 10 AND 20 两端包含
{ store_id: { [Op.in]: [1, 2] } } IN (1, 2) 空数组需明确业务意义
{ email: { [Op.is]: null } } email IS NULL 不要用原始 SQL = NULL
{ email: { [Op.not]: null } } email IS NOT NULL 不是“非空字符串”
{ first_name: { [Op.like]: "ANN%" } } LIKE 'ANN%' 前缀搜索,非包含搜索

整体取反可以写 { [Op.not]: { active: 1, store_id: 1 } },即 NOT (active = 1 AND store_id = 1)。不要误读成两个独立 NOT 用 AND。

时间范围通常使用左闭右开:

{ create_date: { [Op.gte]: start_at, [Op.lt]: next_day_start } }
WHERE create_date >= :start_at AND create_date < :next_day_start

避免手写 23:59:59 漏掉毫秒。Date 的时区转换另见 时区章。

6. NULL、缺失、空字符串是三件事

email: null 表示查数据库 NULL;不提供 email 条件表示不按邮箱筛选;email: "" 表示查空字符串。不能混用。

-- 不会把 NULL 当成“非 x”
WHERE email <> 'x@example.com'

-- 如果产品也要求没有邮箱的客户
WHERE email <> 'x@example.com' OR email IS NULL

SQL 有 TRUE / FALSE / UNKNOWN 三值逻辑,NULL <> 'x' 是 UNKNOWN,不会被 WHERE 保留。

IN ('x', NULL) 也不等于“x 或 NULL”,要显式写 email IN ('x') OR email IS NULL。NOT IN 的列表含 NULL 时尤其容易得到 UNKNOWN,意外排除记录;不要将 nullable 输入不加区分地塞进 IN/NOT IN 数组。

不要构造 { email: undefined } 来表示“忽略条件”。本版本可能拒绝 undefined 值;需要忽略时直接不加键。

本版本 Op.in: [] 会生成 IN (NULL),通常匹配不到记录。服务仍应明确:空列表是返回空结果、拒绝输入,还是清空关系。不要删掉条件导致查全表,更不要认为所有方言和操作符对空数组都一样。

7. 动态条件:不要把 false 和 0 丢了

错误:if (options.active)。传 false 时根本不筛选停用客户。

更清楚的构造方式:

const conditions: WhereOptions<Attributes<Customer>>[] = [];

if (options.active !== undefined) {
  conditions.push({ active: options.active ? 1 : 0 });
}
if (options.store_id !== undefined) {
  conditions.push({ store_id: options.store_id });
}
if (options.name !== undefined && options.name.trim() !== "") {
  const pattern = `%${escapeLike(options.name.trim())}%`;
  conditions.push({
    [Op.or]: [
      { first_name: { [Op.like]: pattern } },
      { last_name: { [Op.like]: pattern } },
    ],
  });
}

const where: WhereOptions<Attributes<Customer>> =
  conditions.length === 0 ? {} : { [Op.and]: conditions };

这里 options 是已校验的 FindCustomersOptions;escapeLike 从项目 utils 导入。条件数量很少时直接写对象即可,不必为了“通用”引入复杂构建器。

0 是否合法取决于字段:分页的 0 应拒绝;某些计数字段的 0 则是有效值。判断“有没有提供”与判断“值是否合法”分开做。

8. LIKE 转义,不是 SQL 注入防护的替代品

例如搜 100%,产品常想找字面百分号,而非 100 后的任意内容。

需要明确、可移植的模式规则时,可在 Raw SQL 指定 ESCAPE,并独立转义模式:

const escaped = keyword.replace(/[!%_]/g, (character) => `!${character}`);
const rows = await db.query<{ customer_id: number }>(
  "SELECT customer_id FROM customer WHERE first_name LIKE :pattern ESCAPE '!'",
  { type: QueryTypes.SELECT, replacements: { pattern: `%${escaped}%` } },
);

! 在这里是指定的 LIKE 转义字符;replacements 仍负责安全传入查询值。两类转义分别解决不同问题。包含搜索 %keyword% 往往不利于普通 B-tree 索引,别只关注语法正确。

9. 当前 WhereBuilder 的能力边界

当前 findCustomers() 用一个 orGroup() 表达名字 OR 姓氏,再与状态等条件 AND,适合这个需求。

但连续调用两次 orGroup(),内部会把两个组的字段都追加到同一个 orParts 数组。结果是:

期望:(A OR B) AND (C OR D)
实际:A OR B OR C OR D

此外,同字段重复 eq() 会覆盖前值;Object.entries() 不读取 Symbol 键,因此它不能完整保留任意嵌套的 Op 条件。

这不是 Sequelize 的限制,而是当前 helper 的实现边界。遇到多组逻辑时先写原生 WhereOptions。本章解释这一点,不修改 helper。

10. 练习与检查清单

  1. 实现 active=0 AND (first_name包含LEE OR last_name包含LEE)。
  2. 实现 (门店1且启用) OR (门店2且无邮箱),与第 4 节对照。
  3. 用缺失、null、空字符串、false 四种输入验证条件是否保留。
  4. 搜索 %、_、反斜杠,确认是否符合产品的字面搜索需求。

写完 WHERE 后问自己:每一组括号是什么?一个停用但姓氏匹配的客户是否会漏进来?参数缺失是否意外变成全表更新?

下一章:attributes 与结果形状。