组合查询条件几乎是每个后端项目绕不开的环节。拿商品列表接口来说,前端可能传来价格区间、分类 ID、关键词、上下架状态等一堆可选参数,后端需要根据实际传入的值动态生成数据库查询。很多人一开始会这样写:先定义一个空对象,然后逐个判断参数是否存在,存在就塞进 where 对象里。这种方式在处理简单等值条件时没问题,可一旦涉及“某个字段不等于某值”“多个字段满足任一条件”“同时满足 A 和 B,或者只满足 C”这类嵌套逻辑,代码就会迅速膨胀成难以阅读的 if 嵌套。Sequelize 提供的操作符和条件构造方法,正好能把这些复杂逻辑整理得井井有条。

这篇文章就从一个真实场景出发:假设我们要实现一个商品搜索接口,查询参数包括关键词(匹配商品名或描述)、最低价、最高价、分类 ID 列表、排除某个特殊分类、上架时间范围以及一个“多选标签”条件(满足任意一个标签即可)。我们会一步一步用 Sequelize 把这些条件组装起来,过程中会重点解释 Op 操作符的嵌套规则,以及如何用函数式写法替代冗长的对象字面量。看完之后,你不但能写出可工作的组合查询,还能理解为什么某些写法容易悄悄漏掉数据。
从基础 where 到 Op 操作符:先把单个条件写正确
Sequelize 的 Model.findOne、findAll、findAndCountAll 等方法都接受一个 options 对象,其中 where 属性用于指定查询条件。最直观的写法是传一个普通对象,键名是字段名,键值是要匹配的值。比如查找所有状态为“在售”的商品,可以写成:
const products = await Product.findAll({
where: {
status: 'on_sale'
}
});
这种写法自动生成 status = 'on_sale' 的 SQL,理解成本很低。但要表达“状态不等于已下架”这种条件,普通对象就无能为力了,必须引入操作符。Sequelize 在 Sequelize.Op 对象上暴露了一组符号,最常见的包括 Op.eq(等于)、Op.ne(不等于)、Op.gt(大于)、Op.gte(大于等于)、Op.lt(小于)、Op.lte(小于等于)、Op.in(包含在数组中)、Op.notIn(不包含在数组中)、Op.between(闭区间)、Op.like(模糊匹配)等。使用时需要先解构出 Op,然后把字段值写成一个对象,键是操作符,值是对应的比较值。比如查找价格在 100 到 500 之间的商品:
const { Op } = require('sequelize');
const products = await Product.findAll({
where: {
price: {
[Op.between]: [100, 500]
}
}
});
注意 [Op.between] 使用了 ES6 的计算属性名语法,实际生成的 SQL 是 price BETWEEN 100 AND 500。这种写法虽然比普通对象复杂一点,但表达力强很多。对于模糊搜索,Op.like 需要配合通配符使用:
const keyword = '手机';
const products = await Product.findAll({
where: {
name: {
[Op.like]: `%${keyword}%`
}
}
});
这里有一个常见的坑:如果 keyword 里本身包含 % 或 _,它们会被当作 SQL 通配符处理,导致结果不符合预期。生产环境下最好对用户输入做转义,或者改用 Op.regexp 并小心构造正则。另一个容易被忽略的点是 Op.in 后面必须跟非空数组。如果前端传来的分类 ID 列表是空数组,很多开发者会不假思索地写上:
const categoryIds = req.body.categoryIds || [];
const where = {
categoryId: { [Op.in]: categoryIds }
};
当 categoryIds 为空时,Sequelize 会生成 categoryId IN (NULL),结果一条数据都查不到,而且不会报错,排查起来很痛苦。正确的做法是只有数组长度大于 0 时才添加这个条件,什么都不传就不加。这个细节在动态拼接条件时至关重要。
组合多个条件:理解 Op.and 与 Op.or 的嵌套结构
单独的等值或范围查询往往满足不了业务需求。搜索接口通常要求“关键词匹配名称或描述”并且“价格在某个区间”并且“分类属于指定列表”,有时还额外要求“如果用户选了标签,则满足任一标签即可”。这些逻辑需要把多个条件用 AND 和 OR 连接起来。Sequelize 允许在 where 顶层直接写多个字段,它们之间默认就是 AND 关系。下面是一个简单示例:
const where = {
status: 'on_sale',
price: { [Op.between]: [100, 500] }
};
这段代码生成 status = 'on_sale' AND price BETWEEN 100 AND 500。但如果想在两个字段之间使用 OR,比如“名称包含关键词 OR 描述包含关键词”,就不能直接平铺了,必须使用 Op.or 显式声明。写法是把 Op.or 作为键,值是一个数组,数组里的每一项都是一个条件对象:
const { Op } = require('sequelize');
const keyword = '蓝牙耳机';
const where = {
[Op.or]: [
{ name: { [Op.like]: `%${keyword}%` } },
{ description: { [Op.like]: `%${keyword}%` } }
]
};
此时生成的 SQL 是 (name LIKE '%蓝牙耳机%' OR description LIKE '%蓝牙耳机%')。注意 Op.or 的值必须是数组,哪怕只有一个条件也要写成数组形式。同时,在 where 顶层同时出现普通字段和 Op.or 时,它们之间的关系是 AND。比如:
const where = {
status: 'on_sale',
[Op.or]: [
{ name: { [Op.like]: '%蓝牙%' } },
{ description: { [Op.like]: '%蓝牙%' } }
]
};
生成的 SQL 是 status = 'on_sale' AND (name LIKE '%蓝牙%' OR description LIKE '%蓝牙%')。这个行为符合直觉,但很多人会想当然地认为 Op.or 数组会和 status 平级,导致优先级出错。其实只要记住:Op.and 和 Op.or 各自聚合自己数组内的条件,数组外部与其他字段之间是 AND 关系,就能避免大部分误解。
如果业务要求更复杂,比如“上架时间在最近一周且状态为在售,或者价格低于 50 元的特价商品”,就需要显式使用 Op.and 和 Op.or 嵌套。Sequelize 允许在 Op.and 或 Op.or 的数组项中再次放置操作符,形成任意深度的逻辑树。下面是一个嵌套示例:
const { Op } = require('sequelize');
const oneWeekAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
const where = {
[Op.or]: [
{
[Op.and]: [
{ status: 'on_sale' },
{ publishTime: { [Op.gte]: oneWeekAgo } }
]
},
{
price: { [Op.lt]: 50 },
isSpecial: true
}
]
};
这段代码读起来比较绕,但结构清晰:外层 OR 连接两个分支,第一个分支内部是 AND,第二个分支内部有两个普通字段,它们之间默认 AND。为了提升可读性,可以先把每个分支提取成独立变量:
const branchA = {
[Op.and]: [
{ status: 'on_sale' },
{ publishTime: { [Op.gte]: oneWeekAgo } }
]
};
const branchB = {
price: { [Op.lt]: 50 },
isSpecial: true
};
const where = {
[Op.or]: [branchA, branchB]
};
这样后续维护时只需关注分支内部逻辑。需要特别指出,Op.and 在 where 顶层写不写都行,因为顶层默认就是 AND。但一旦出现在 Op.or 数组内部,就必须用 Op.and 包裹多条条件,否则数组项里的普通字段之间默认是 AND,但不会被整体当作一个分支吗?实际上数组项本身就是一个对象,对象内部多个字段就是 AND,所以 Op.and 在数组项内部是可选的。不过为了语义明确,哪怕只是单字段条件,也建议在复杂嵌套中显式使用 Op.and,避免后续添加字段时产生困惑。
函数式条件构造与动态拼接实践
面对多参数可选的前端请求,直接在代码里写一个大 where 字面量会非常笨拙。更优雅的方式是逐条判断参数是否存在,存在则把对应条件 push 到一个条件数组中,最后用 Op.and 包裹起来。这样不仅结构清晰,还能避免空数组、undefined 等边界问题。假设前端传来以下参数:keyword、minPrice、maxPrice、categoryIds、excludeCategoryId、tagIds、startTime、endTime。我们可以这样构建:
const { Op } = require('sequelize');
function buildProductWhere(params) {
const conditions = [];
// 关键词搜索:名称或描述模糊匹配
if (params.keyword) {
const kw = `%${params.keyword}%`;
conditions.push({
[Op.or]: [
{ name: { [Op.like]: kw } },
{ description: { [Op.like]: kw } }
]
});
}
// 价格区间:只有传入最小值或最大值时才添加对应条件
if (params.minPrice !== undefined || params.maxPrice !== undefined) {
const priceCondition = {};
if (params.minPrice !== undefined) {
priceCondition[Op.gte] = params.minPrice;
}
if (params.maxPrice !== undefined) {
priceCondition[Op.lte] = params.maxPrice;
}
conditions.push({ price: priceCondition });
}
// 分类列表:非空数组才添加 in 条件
if (Array.isArray(params.categoryIds) && params.categoryIds.length > 0) {
conditions.push({ categoryId: { [Op.in]: params.categoryIds } });
}
// 排除特定分类:注意 Op.ne 或 Op.notIn
if (params.excludeCategoryId !== undefined) {
conditions.push({ categoryId: { [Op.ne]: params.excludeCategoryId } });
}
// 多选标签:满足任意一个标签
if (Array.isArray(params.tagIds) && params.tagIds.length > 0) {
conditions.push({
[Op.or]: params.tagIds.map(tagId => ({ tagId }))
});
}
// 上架时间范围:注意日期边界
if (params.startTime || params.endTime) {
const timeCondition = {};
if (params.startTime) {
timeCondition[Op.gte] = new Date(params.startTime);
}
if (params.endTime) {
timeCondition[Op.lte] = new Date(params.endTime);
}
conditions.push({ publishTime: timeCondition });
}
// 如果没有任何条件,返回空对象,Sequelize 会查询全部
if (conditions.length === 0) {
return {};
}
return { [Op.and]: conditions };
}
这个函数将所有条件统一放到 conditions 数组里,每个元素都是一个完整的条件对象,最后用 Op.and 连接。这样设计的好处是每个条件独立,测试起来容易,也不需要为了某个参数的存在与否写大量 if-else。调用时代码非常简洁:
const where = buildProductWhere(req.query);
const products = await Product.findAll({ where });
这里还有个细节值得注意:buildProductWhere 接收的 params 来自 req.query,所以价格等数值都是字符串,需要在使用前转成数字,否则 Sequelize 生成的 SQL 可能会发生隐式类型转换,虽然一般数据库能处理,但显式转换更稳妥。另外,当数组元素是 map 生成的单字段对象时,注意返回的对象不能有 undefined 值,否则条件会被忽略。上述代码中 tagId 来自数组元素,一定存在,所以安全。
有些场景下,我们需要对两个字段进行比较,例如“库存数量小于预警值”。Sequelize 的普通 where 对象无法表达字段与字段的比较,必须使用 Sequelize.where 函数。它接受两个参数:第一个是字段名(可以是字符串或列引用),第二个是操作符和值。典型写法如下:
const Sequelize = require('sequelize');
const products = await Product.findAll({
where: Sequelize.where(
Sequelize.col('stock'),
Op.lt,
Sequelize.col('alertThreshold')
)
});
这段代码生成 stock < alertThreshold 的 SQL。也可以把 Sequelize.where 结果放在 Op.and 条件数组里,与其他普通条件混用。如果需要在 where 中嵌入原生 SQL 片段,例如对 JSON 字段做复杂查询,可以使用 Sequelize.literal:
const products = await Product.findAll({
where: {
[Op.and]: [
Sequelize.literal("JSON_EXTRACT(attributes, '$.color') = 'red'")
]
}
});
需要注意 Sequelize.literal 中的 SQL 字符串必须谨慎构造,如果拼接了用户输入,存在 SQL 注入风险。官方建议尽可能使用 Sequelize.where 和 fn 替代,只有在无法用标准操作符表达时才用字面量。还有一个实用的函数是 Sequelize.fn,配合 Sequelize.col 可以调用数据库函数,比如对日期字段做筛选:
const products = await Product.findAll({
where: Sequelize.where(
Sequelize.fn('YEAR', Sequelize.col('publishTime')),
Op.eq,
2024
)
});
虽然这属于高级用法,但理解它们之后,组合查询的灵活性会大大提升。核心思路始终不变:先用普通对象表达简单等值,再用 Op 操作符表达比较和集合条件,遇到字段间比较或函数运算时借助 Sequelize.where 和 fn/col,最后用 Op.and 或 Op.or 组织逻辑层级。动态拼接时优先使用条件数组加 Op.and 包裹的模式,能极大降低代码复杂度,也方便后续维护和单元测试。
总结一下,Sequelize 的复杂组合查询并没有那么神秘。掌握 Op 操作符的嵌套规则、养成条件数组动态拼接的习惯、必要时启用 Sequelize.where 处理跨字段比较,就能覆盖绝大多数业务需求。更重要的是,在写组合条件时要时刻想想 SQL 最终会被翻译成什么样子,尤其是空数组、日期边界和操作符优先级这几个点,多留个心眼能避免上线后出现诡异的查询结果。