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 注入防护的替代品
%匹配任意长度文本;_匹配单个字符。- Sequelize 处理查询值的 SQL 转义,不代表自动取消 LIKE 通配符意义。
- 当前
escapeLike()转义反斜杠、%和_,适用于项目既定的 MySQL LIKE 转义约定;应以实际sql_mode和测试结果验证,不把它当跨方言通用方案。
例如搜 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. 练习与检查清单
- 实现
active=0 AND (first_name包含LEE OR last_name包含LEE)。 - 实现
(门店1且启用) OR (门店2且无邮箱),与第 4 节对照。 - 用缺失、
null、空字符串、false四种输入验证条件是否保留。 - 搜索
%、_、反斜杠,确认是否符合产品的字面搜索需求。
写完 WHERE 后问自己:每一组括号是什么?一个停用但姓氏匹配的客户是否会漏进来?参数缺失是否意外变成全表更新?
下一章:attributes 与结果形状。