# 织链塔:12 步从零造一个 Koa 后端框架 —— 第 4 步:JSON 条件编译引擎(buildWhere)

第 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会把,如果用 lastIndexOf 会把 ,如果用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 条件。

相关推荐
coderCN1 小时前
NestJS + Prisma + PostgreSQL
后端
苍何1 小时前
开源微信流 APP,手机微信聊天记录,可以直接给 WorkBuddy 和 Obsidian 了
后端
智驭未来掌门人2 小时前
Qt 项目不建议导入 CLion 说明
后端
Lumos_2 小时前
P1 签名 (PKCS#1) vs 裸签 (Raw Signature)
后端
今天早睡2 小时前
调用真实模型,用 Pydantic 校验后再路由
后端
智驭未来掌门人2 小时前
CLion 创建并运行 Qt 项目指南
后端
数据库小学妹2 小时前
OLAP数据库怎么选、怎么优化?路线、提速与边界
运维·数据库·后端
加洛斯2 小时前
万字推演Elasticsearch修炼方法,从炼气到元婴
后端·elasticsearch·面试
当时只道是寻常_2 小时前
WorkBuddy中调用本地ComfyUi工作流免费生图
后端