第 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)。