第 2 章:裸写 Query(原生驱动)

第 2 章:裸写 Query(原生驱动)

问题

第 1 章的 buildWhere(json, schema) 已经跑通了:JSON → {sql, params},手动 schema 做字段白名单和类型转换。零依赖、纯函数。

但编译出来的东西还没真的执行过 ------现在需要一个数据库,把 {sql, params} 喂进去,拿到真实数据。

选什么数据库?现在不选。先选一个驱动,裸写查询函数------写着写着自然会暴露问题。


随便挑一个驱动先跑起来

纯 Node.js 生态里有几个数据库驱动:

  • better-sqlite3 :SQLite 同步驱动,不需要 await ,写 demo 脚本时特别爽------db.prepare("select * from orders").all() 一行搞定
  • pg :PostgreSQL 异步驱动,await client.query(...)
  • mysql2 :MySQL 异步驱动,await connection.execute(...)
  • mongodb:MongoDB 异步驱动(但 MongoDB 是文档型,不适合关系型 SQL)

选 better-sqlite3 先跑。原因很简单:内存数据库,同步 API,不用等,demo 脚本直接贴进去就能跑,连启动数据库服务都省了。

bash 复制代码
npm init -y
npm install better-sqlite3

第一个查询:把 buildWhere 喂给原生驱动

js 复制代码
const Database = require("better-sqlite3")
const { buildWhere } = require("./lib/builder-where")

const db = new Database(":memory:") // 内存数据库,重启就没了

// 建表
db.exec(`
  CREATE TABLE orders (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    status INTEGER NOT NULL,
    amount REAL NOT NULL,
    name TEXT,
    created_at TEXT
  )
`)

// 插入几条种子数据
db.prepare("INSERT INTO orders (status, amount, name) VALUES (?, ?, ?)").run(1, 100, "订单A")
db.prepare("INSERT INTO orders (status, amount, name) VALUES (?, ?, ?)").run(2, 250, "订单B")

// buildWhere 编译 where 条件
const { sql, params } = buildWhere({ status: 1, amount$gt: 50 })
// sql = "status = ? AND amount > ?"
// params = [1, 50]

// 拼完整 SQL
const fullSql = `SELECT * FROM orders WHERE ${sql}`
const rows = db.prepare(fullSql).all(...params) // better-sqlite3 的 all() 接受展开的 params
console.log(rows)

跑通了。但这是最原始的方式 ------每个查询都要手动拼 SELECT * FROM 表名 WHERE ${sql},手动 db.prepare(...).all(...),手动加分页排序。我要封装成通用函数。


封装 query.js:第一版原生驱动

先写能跑的版本------用 better-sqlite3 的 API,支持 where + 分页 + 排序 + 字段投影:

js 复制代码
// lib/query.js
const Database = require("better-sqlite3")
const { buildWhere } = require("./builder-where")

// 全局持有一个 db 实例(这里硬编码内存数据库,后面会改成外部传入)
let _db = null

function init(dbPath = ":memory:") {
  _db = new Database(dbPath)
  _db.pragma("journal_mode = WAL")
  return _db
}

function query(table, opts = {}) {
  if (!_db) throw new Error("query 需要先调用 init() 初始化数据库")

  let { fields, sort, top, page, size, ...where } = opts

  // 1. 编译 where(手动 schema,暂时从外面传)
  const schema = opts.schema // 调用方传入手动 schema 对象
  const { sql: whereSql, params } = buildWhere(where, schema)

  // 2. 字段投影
  const selectFields = fields ? fields.join(", ") : "*"

  // 3. 拼基础 SQL
  let sql = `SELECT ${selectFields} FROM ${table} WHERE ${whereSql || "1=1"}`

  // 4. 排序
  if (sort) {
    const sortParts = []
    for (const [f, d] of Object.entries(sort)) {
      sortParts.push(`${f} ${String(d).toUpperCase()}`)
    }
    sql += ` ORDER BY ${sortParts.join(", ")}`
  }

  // 5. 执行查询
  if (top !== undefined) {
    sql += ` LIMIT ${top}`
    return _db.prepare(sql).all(...params)
  }

  if (page !== undefined && size !== undefined) {
    if (page < 1) throw new Error("page 必须 >= 1")
    if (size < 1) throw new Error("size 必须 >= 1")

    // 分页需要两个查询:数据 + 总数
    const dataSql = sql + ` LIMIT ${size} OFFSET ${(page - 1) * size}`
    const rows = _db.prepare(dataSql).all(...params)

    const countSql = `SELECT COUNT(*) as total FROM ${table} WHERE ${whereSql || "1=1"}`
    const totalRow = _db.prepare(countSql).get(...params)

    return { rows, total: totalRow.total, page, size }
  }

  // 不分页不分 top,全返回
  return _db.prepare(sql).all(...params)
}

module.exports = { query, init }

跑通标志:

js 复制代码
// demo-query.js
const { query, init } = require("./lib/query")

const db = init(":memory:")
db.exec("CREATE TABLE orders (id INTEGER PRIMARY KEY AUTOINCREMENT, status INTEGER, amount REAL)")
db.prepare("INSERT INTO orders (status, amount) VALUES (?, ?)").run(1, 100)
db.prepare("INSERT INTO orders (status, amount) VALUES (?, ?)").run(2, 250)

// 手动 schema
const ordersSchema = {
  allowed: new Set(["id", "status", "amount"]),
  types: { id: "number", status: "number", amount: "number" }
}

// 条件查询
const r1 = query("orders", { status: 1, amount$gt: 50, schema: ordersSchema })
console.log(r1) // [{ id: 1, status: 1, amount: 100 }]

// 分页
const r2 = query("orders", { schema: ordersSchema, page: 1, size: 1 })
console.log(r2) // { rows: [...], total: 2, page: 1, size: 1 }

// 排序
const r3 = query("orders", { schema: ordersSchema, sort: { amount: "desc" } })
console.log(r3) // [{ id: 2, amount: 250 }, { id: 1, amount: 100 }]

跑通了。但写着写着,一些问题开始让我烦躁。


裸写时暴露的问题

问题 1:LIMIT/OFFSET 语法每个方言不一样

我用的 SQLite 是 LIMIT n OFFSET m。但:

  • MySQL :也是 LIMIT n OFFSET m,一样
  • PostgreSQL :也是 LIMIT n OFFSET m,一样------那行吧
  • SQL Server :完全不一样!OFFSET m ROWS FETCH NEXT n ROWS ONLY
  • Oracle :更不一样!要套子查询 SELECT * FROM (SELECT t.*, ROWNUM rn FROM orders t WHERE ROWNUM <= m+n) WHERE rn > m

如果我以后想换 SQL Server,这段分页 SQL 就得重写。

问题 2:返回自增 ID 的方式每个方言不一样

写操作之后想拿到新插入行的 ID:

  • SQLite :db.prepare("INSERT ...").run(...).lastInsertRowid(同步驱动特有的)
  • MySQL :result.insertId(mysql2 异步驱动的返回值里有)
  • PostgreSQL :必须写 INSERT ... RETURNING id(pg 驱动里 result.rows[0].id)
  • SQL Server :INSERT ... OUTPUT INSERTED.id

我现在用 better-sqlite3,.lastInsertRowid 就够了。但哪天换 PostgreSQL?换 MySQL?整个 create 逻辑都要改。

问题 3:字段名转义

现在字段名直接拼进 SQL:

js 复制代码
;`SELECT ${selectFields} FROM ${table} WHERE ${whereSql}`

table、fields、sort 里的字段名都没有走转义。better-sqlite3 里字段名如果和 SQLite 保留字冲突(比如 name、group、order),会报错。需要用引号包起来------但:

  • SQLite:"name" 或 [name]
  • MySQL:name
  • PostgreSQL:"name"

方言不一样。

问题 4:数据库连接管理

better-sqlite3 是同步的,一个 new Database(path) 就完了。但如果换 async 驱动(pg、mysql2),就需要连接池、await connection.begin() 事务、await connection.commit() 提交------完全不同的 API。

问题 5:我不想每次手动传 schema

手动 schema 可以用,但太烦。10 张表要写 10 遍 { allowed: new Set([...]), types: { ... } }。这些信息数据库里本来就有,能不能自动拉?


找个抽象层把方言差异一锅端

上面 1-4 全是方言问题。我需要一个抽象层,帮我屏蔽不同数据库的 SQL 写法和 API 差异,让我写的代码在 SQLite / MySQL / PostgreSQL / SQL Server 上都能跑。

Node.js 生态里这种抽象层有两类:

类型 A:ORM(抽象层太厚)

  • Prisma:用自己的 schema 文件定义表结构,生成 migration,然后用 Prisma Client 的 TS 风格 API 操作
  • Sequelize:表结构定义在 JS/TS 类里,用 model.find() / model.create() 操作
  • TypeORM:和 Sequelize 类似,装饰器定义表结构

ORM 能解决方言问题,但它会让我用 ORM 自己的模型定义来替代原生建表语句 ,然后用 ORM 的 API 替代 SQL。我已经有 buildWhere 这个 JSON → SQL 编译器了,它产出的是原生 SQL 片段,我需要的是能接受原生 SQL 片段、帮我处理方言差异的工具,而不是让我把 ORM 的 API 重新学一遍的完整 ORM。

类型 B:查询构建器(刚好够用)

  • Knex.js :SQL 查询构建器,链式 API .select().where().limit().offset(),底层帮我屏蔽所有方言差异 。而且它有一个关键能力------knex.raw(sql, bindings) 可以直接塞原生 SQL 片段 ,包括我自己编译的 buildWhere 产物。

这就是我要的。Knex 刚好卡在"原生驱动"和"ORM"之间:

js 复制代码
// Knex 链式 API(日常用)
knex("orders").select("*").where("status", 1).limit(10).offset(0)

// Knex 的 raw 能力(关键!能塞我自己编译的 SQL 片段)
const { sql, params } = buildWhere({ status: 1, amount$gt: 50 })
knex("orders").whereRaw(sql, params)

Knex 帮我处理:

问题 Knex 怎么解决
LIMIT/OFFSET 方言差异 .limit(10).offset(0),Knex 根据 client 自动生成正确的 SQL
自增 ID 返回 .insert(data).returning("*"),Knex 根据方言自动用 RETURNING / OUTPUT / lastInsertRowid
字段名转义 .select("name") → Knex 自动加方言对应的引号,knex.raw('??', ['name']) 手动 escape 也行
连接管理 Knex 内部管理连接池,.transaction() 帮你处理 begin/commit
方言切换 改 client: "sqlite3" → client: "pg" → client: "mysql2",其他代码不动

而且 Knex 还内置了一个能力:.columnInfo() 能拿表结构(虽然格式不统一,但我可以找个包帮忙统一)。这能解决问题 5------自动拉 schema。

下一章:把原生 query.js 换成 Knex 版 ,找 knex-schema-inspector 统一各方言的表结构返回格式,写 schema-cache 缓存防击穿。之前的 buildWhere 还是那个 buildWhere,只是调用方从 db.prepare(sql).all() 换成了 knex(table).whereRaw(sql, params)。

相关推荐
用户574385305111 小时前
第 8 章:Service 层的诞生(硬编码版)
后端
用户574385305111 小时前
第 3 章:引入 Knex + Schema 自动加载
后端
flash俊杰1 小时前
合并式恢复的事务边界:幂等跳过、外键顺序与向量重建标记
后端
学长毕设1 小时前
基于springboot的实验室设备管理系统
java·spring boot·后端
学长毕设2 小时前
基于springboot的社区养老监护系统的设计与实现
java·spring boot·后端
会编程的吕洞宾2 小时前
RAG检索质量怎么评估?3个指标+LLM裁判实战
java·开发语言·人工智能·后端
wno7042 小时前
Springboot国际化信息(i18n)解析
java·spring boot·后端
郭小李3 小时前
spring 的 @CookieValue 注解 用法
java·后端·spring
god_cvz3 小时前
麒麟 V10(ARM64 / aarch64)源码编译 3dtiles 全步骤
后端·gis