第 4 步:JSON 条件编译引擎(buildWhere)
🧠 知识点简介
为什么不直接用 Knex 的 .where()
Knex 的 .where() 只能处理简单键值:
js
knex("orders").where({ status: 1, amount: 100 })
// → WHERE "status" = ? AND "amount" = ?
但 RBAC 场景下,我们需要用户(或系统)能灵活表达复杂条件:
json
{
"status": "paid",
"amount@$gt": 1000,
"$or": [{ "created_by@$eq": 1 }, { "dept_id@$eq": 3 }]
}
这段 JSON 要编译成:
sql
WHERE "status" = ? AND "amount" > ? AND ("created_by" = ? OR "dept_id" = ?)
-- 带三个 ? 占位符,params = ["paid", 1000, 1, 3]
Knex 自己做不了这个------它没有递归解析 JSON 条件树的能力。所以我们自己造。
为什么 JSON 格式
| 格式 | 优点 | 缺点 |
|---|---|---|
| 直接传 SQL 字符串 | 灵活 | ❌ 有 SQL 注入风险,而且 Knex .whereRaw 没法自动适配方言 |
{ field: value } 简单键值 |
简单 | ❌ 表达不了 OR / 大于 / LIKE / IN / 嵌套 |
JSON 条件树 |
✅ 递归可解析,覆盖所有场景 | 需要我们自己写编译器 |
语法规范(先约定,再写代码)
三种分隔符,优先级 @ > __ > $
字段名和操作符之间可以用三种分隔符拆分:
ini
age@$gt → field="age", op="$gt" ← 推荐,@ 最清晰
age__gt → field="age", op="$gt" ← 下划线也可以
age$gt → field="age", op="$gt" ← 也能识别
age → field="age", op="$eq" ← 无分隔符默认等值查询
为什么三种?不同前端组件库可能用不同风格,我们全兼容。
13 种操作符
| 分类 | 操作符 | SQL | 例子 |
|---|---|---|---|
| 比较 | $eq $ne $gt $lt $gte $lte |
= != > < >= <= |
age@$gte: 18 |
| 模糊 | $like $nlike |
LIKE / NOT LIKE |
name@$like: "Mac" |
| 空值 | $is $nis |
IS / IS NOT |
deleted_at@$is: null |
| 集合 | $in $nin |
IN / NOT IN |
dept_id@$in: [1,3,5] |
| 区间 | $between $nbetween |
BETWEEN / NOT BETWEEN |
amount@$between: [100, 500] |
逻辑节点
json
{
"$or": [{ "field1": "val1" }, { "field2": "val2" }]
}
$or 的值是数组,每个元素又是一个条件对象(可以嵌套 $or / $keyword / 普通键)。对象自身内部的条件默认 AND 连接。
$keyword 脱糖语法
json
{ "$keyword": ["Mac", "name", "sku"] }
等价于:
json
{
"$or": [{ "name@$like": "Mac" }, { "sku@$like": "Mac" }]
}
一行代码能省掉嵌套层级,常见于前端搜索框。
🎯 本节目标
- 实现
lib/builder-where.js - 输入:JSON 条件对象 + 可选的 schema(做字段白名单 + 类型转换)
- 输出:
{ sql, params, columns } - 前置依赖:02(errors)、03(schema-cache)
🛠️ 动手实现
核心思路:三层递归编译
scss
buildWhere(json, schema)
│
├─ 1. buildAnd(json, columns) ← 递归解析条件树,生成 SQL 片段 + 收集原始值
│ │
│ ├─ $or → 递归每个子对象,用 OR 拼接
│ ├─ $keyword → 脱糖成 $or + 多个 $like
│ └─ 普通键 → parseKey 拆 field+op → buildExpr 生成 SQL + 推入 columns
│
├─ 2. applySchema(columns, schema) ← 有 schema 就校验字段白名单 + 值类型转换
│
└─ 3. columns → params ← 拆成纯值数组
完整代码:lib/builder-where.js
js
const { BadRequest } = require("./errors")
const FIELD_RE = /^[a-zA-Z_][a-zA-Z0-9_.]*$/
function assertField(field) {
if (!FIELD_RE.test(field)) throw new BadRequest(`非法字段名: ${field}`)
}
const OP_MAP = {
$gt: ">",
$lt: "<",
$gte: ">=",
$lte: "<=",
$ne: "!=",
$eq: "=",
$like: "like",
$nlike: "not like",
$is: "is",
$nis: "is not",
$in: "in",
$nin: "not in",
$between: "between",
$nbetween: "not between"
}
function likeValue(v) {
if (v.includes("%") || v.includes("_")) return v.replace(/%/g, "\\%").replace(/_/g, "\\_")
if (v.includes("*")) return v.replace(/\*/g, "%")
return `%${v}%`
}
function parseKey(key) {
for (const [sep, len] of [
["@", 1],
["__", 2],
["$", 1]
]) {
const i = key.indexOf(sep)
if (i > 0) {
let op = key.slice(i + len)
if (!op.startsWith("$") && /^[a-z]/i.test(op)) op = "$" + op
return { field: key.slice(0, i), op }
}
}
return { field: key, op: "$eq" }
}
function buildExpr(field, op, value, columns) {
assertField(field)
if (value === null) {
if (op === "$ne") return `${field} is not null`
if (op === "$eq" || !OP_MAP[op]) return `${field} is null`
}
const sqlOp = OP_MAP[op] || op
if (op === "$in" || op === "$nin") {
const placeholders = value.map(() => "?").join(", ")
columns.push(...value.map((v) => ({ [field]: v })))
return `${field} ${sqlOp} (${placeholders})`
}
if (op === "$between" || op === "$nbetween") {
columns.push({ [field]: value[0] }, { [field]: value[1] })
return `${field} ${sqlOp} ? AND ?`
}
if (op === "$like" || op === "$nlike") {
columns.push({ [field]: likeValue(value) })
return `${field} ${sqlOp} ?`
}
columns.push({ [field]: value })
return `${field} ${sqlOp} ?`
}
function buildAnd(obj, columns) {
const parts = []
for (const key of Object.keys(obj)) {
if (key === "$or") {
const orParts = obj[key].map((sub) => {
const r = buildAnd(sub, columns)
return r.isCompound ? `(${r.sql})` : r.sql
})
parts.push(`(${orParts.join(" OR ")})`)
} else if (key === "$keyword") {
const [word, ...fields] = obj[key]
const r = buildAnd({ $or: fields.map((f) => ({ [`${f}@like`]: word })) }, columns)
parts.push(r.sql)
} else {
const { field, op } = parseKey(key)
parts.push(buildExpr(field, op, obj[key], columns))
}
}
return { sql: parts.join(" AND "), isCompound: parts.length > 1 }
}
function applySchema(columns, schema) {
for (const col of columns) {
const field = Object.keys(col)[0]
if (!(field in schema)) throw new BadRequest(`非法查询字段: ${field}`)
const raw = col[field]
if (raw === null || raw === undefined) continue
const type = schema[field]
if (type === "number") {
const n = Number(raw)
if (Number.isNaN(n)) throw new BadRequest(`字段 ${field} 无法转为 number: ${raw}`)
col[field] = n
} else {
col[field] = String(raw)
}
}
}
function buildWhere(json, schema) {
const columns = []
const { sql } = buildAnd(json, columns)
if (schema) applySchema(columns, schema)
const params = columns.map((p) => Object.values(p)[0])
return { sql, params, columns }
}
module.exports = { buildWhere }
设计要点
parseKey 按 indexOf 取最左分隔符
"age@$gt" 里同时有 @ 和 ,如果用lastIndexOf会把 当分隔符,field 变成 "age@" 就错了。必须用 indexOf 取最左。
$or 子分支的括号
js
$or: [
{ "created_by@$eq": 1, "status@$ne": "archived" }, // 多条件,需要括号
{ "dept_id@$eq": 3 } // 单条件,可选
]
// 生成: (("created_by" = ? AND "status" != ?) OR "dept_id" = ?)
buildAnd 返回 isCompound 标记当前对象是否有多个条件,多条件分支放进 $or 时自动加括号。
applySchema 放在编译层外面
编译层(buildExpr/buildAnd)只负责"怎么拼 SQL",schema 校验 + 类型转换是业务层约束。两层解耦后,纯编译可以独立使用------不传 schema 就原样透传,比如 RBAC 的 row_rule 编译时可能不需要 schema 校验。
✅ 验证
写 demo-where.js:
js
const { buildWhere } = require("./lib/builder-where")
const knex = require("./db")
const { getSchema } = require("./lib/schema-cache")
;(async () => {
console.log("=== 1. 基础等值 ===")
console.log(buildWhere({ status: "paid" }))
// { sql: "status = ?", params: ["paid"] }
console.log("\n=== 2. 多操作符 ===")
console.log(buildWhere({ status: "paid", amount__gt: 100, "created_at@$is": null }))
// { sql: "status = ? AND amount > ? AND created_at is null", params: ["paid", 100, null] }
console.log("\n=== 3. $or 嵌套 ===")
console.log(
buildWhere({
status: "pending",
$or: [{ created_by: 1 }, { "dept_id@$in": [3, 5] }]
})
)
// { sql: "status = ? AND (created_by = ? OR dept_id in (?, ?))", params: ["pending", 1, 3, 5] }
console.log("\n=== 4. $keyword 脱糖 ===")
console.log(buildWhere({ $keyword: ["Mac", "name", "sku"] }))
// { sql: "(name like ? OR sku like ?)", params: ["%Mac%", "%Mac%"] }
console.log("\n=== 5. 带 schema 校验 + 类型转换 ===")
const schema = await getSchema(knex, "orders")
console.log(buildWhere({ amount__gt: "1000" }, schema.types))
// amount 是 number 类型,字符串 "1000" 被自动转成数字 1000
console.log("\n=== 6. 非法字段抛错 ===")
try {
buildWhere({ not_exist_field: 1 }, schema.types)
} catch (e) {
console.log("✅ 抛出 BadRequest:", e.message)
}
await knex.destroy()
})()
跑一下:
bash
node demo-where.js
预期输出和注释一致。
📝 小结
| 知识点 | 一句话 |
|---|---|
| JSON 条件编译 | 递归解析条件树 → 生成 SQL + params,解决了 Knex .where() 表达不了的复杂条件 |
| 三层分隔符 | @ __ $ 兼容不同代码风格的 field@op 写法 |
| $or 括号问题 | 用 isCompound 标记自动加括号,避免 AND/OR 优先级歧义 |
| 编译层 vs schema 层 | 解耦后纯编译可独立使用(RBAC row_rule),也可叠加 schema 校验(业务查询) |
| likeValue | % / _ 自动转义、* 通配符、默认子串模糊匹配 |
这个引擎是整个项目的动力核心------create-crud 用它编译用户查询,data-permission 用它编译 RBAC 规则。没有它,行级权限就只能在 service 层写 if/else 硬编码,灵活度为零。
下一步我们造 CRUD 工厂------一张表 = 一套 CRUD 方法,底层靠 schema-cache 自动读表结构、靠 buildWhere 编译 where 条件。