1 引言
前端精讀《手寫 SQL 編譯器系列》 介紹了如何利用 SQL 生成語(yǔ)法樹(shù),而還有一些庫(kù)的作用是根據(jù)語(yǔ)法樹(shù)生成 SQL 語(yǔ)句。
除此之外,還有一種庫(kù),是根據(jù)編程語(yǔ)言生成 SQL。sqorn 就是一個(gè)這樣的庫(kù)。
可能有人會(huì)問(wèn),利用編程語(yǔ)言生成 SQL 有什么意義?既沒(méi)有語(yǔ)法樹(shù)規(guī)范,也不如直接寫 SQL 通用。對(duì),有利就有弊,這些庫(kù)不遵循語(yǔ)法樹(shù),但利用簡(jiǎn)化的對(duì)象模型快速生成 SQL,使得代碼抽象程度得到了提高。而代碼抽象程度得到提高,第一個(gè)好處就是易讀,第二個(gè)好處就是易操作。
數(shù)據(jù)庫(kù)特別容易抽象為面向?qū)ο竽P?,而?duì)數(shù)據(jù)庫(kù)的操作語(yǔ)句 - SQL 是一種結(jié)構(gòu)化查詢語(yǔ)句,只能描述一段一段的查詢,而面向?qū)ο竽P蛥s適合描述一個(gè)整體,將數(shù)據(jù)庫(kù)多張表串聯(lián)起來(lái)。
舉個(gè)例子,利用 typeorm,我們可以用 a 與 b 兩個(gè) Class 描述兩張表,同時(shí)利用 ManyToMany 裝飾器分別修飾 a 與 b 的兩個(gè)字段,將其建立起 多對(duì)多的關(guān)聯(lián),而這個(gè)映射到 SQL 結(jié)構(gòu)是三張表,還有一張是中間表 ab,以及查詢時(shí)涉及到的 left join 操作,而在 typeorm 中,一條 find 語(yǔ)句就能連帶查詢處多對(duì)多關(guān)聯(lián)關(guān)系。
這就是這種利用編程語(yǔ)言生成 SQL 庫(kù)的價(jià)值,所以本周我們分析一下 sqorn 這個(gè)庫(kù)的源碼,看看利用對(duì)象模型生成 SQL 需要哪些步驟。
2 概述
我們先看一下 sqorn 的語(yǔ)法。
const sq = require("sqorn-pg")();
const Person = sq`person`,
Book = sq`book`;
// SELECT
const children = await Person`age < ${13}`;
// "select * from person where age < 13"
// DELETE
const [deleted] = await Book.delete({ id: 7 })`title`;
// "delete from book where id = 7 returning title"
// INSERT
await Person.insert({ firstName: "Rob" });
// "insert into person (first_name) values ('Rob')"
// UPDATE
await Person({ id: 23 }).set({ name: "Rob" });
// "update person set name = 'Rob' where id = 23"
首先第一行的 sqorn-pg 告訴我們 sqorn 按照 SQL 類型拆成不同分類的小包,這是因?yàn)椴煌瑪?shù)據(jù)庫(kù)支持的方言不同,sqorn 希望在語(yǔ)法上抹平數(shù)據(jù)庫(kù)間差異。
其次 sqorn 也是利用面向?qū)ο笏季S的,上面的例子通過(guò) <code>sq`person`</code> 生成了 Person 實(shí)例,實(shí)際上也對(duì)應(yīng)了 person 表,然后 <code>Person`age < ${13}`</code> 表示查詢:select * from person where age < 13
上面是利用 ES6 模板字符串的功能實(shí)現(xiàn)的簡(jiǎn)化 where 查詢功能,sqorn 主要還是利用一些函數(shù)完成 SQL 語(yǔ)句生成,比如 where delete insert 等等,比較典型的是下面的 Example:
sq.from`book`.return`distinct author`
.where({ genre: "Fantasy" })
.where({ language: "French" });
// select distinct author from book
// where language = 'French' and genre = 'Fantsy'
所以我們閱讀 sqorn 源碼,探討如何利用實(shí)現(xiàn)上面的功能。
3 精讀
我們從四個(gè)方面入手,講明白 sqorn 的源碼是如何組織的,以及如何滿足上面功能的。
方言
為了實(shí)現(xiàn)各種 SQL 方言,需要在實(shí)現(xiàn)功能之前,將代碼拆分為內(nèi)核代碼與拓展代碼。
內(nèi)核代碼就是 sqorn-sql 而拓展代碼就是 sqorn-pg,拓展代碼自身只要實(shí)現(xiàn) pg 數(shù)據(jù)庫(kù)自身的特殊邏輯, 加上 sqorn-sql 提供的核心能力,就能形成完整的 pg SQL 生成功能。
實(shí)現(xiàn)數(shù)據(jù)庫(kù)連接
sqorn 不但生成 query 語(yǔ)句,也會(huì)參與數(shù)據(jù)庫(kù)連接與運(yùn)行,因此方言庫(kù)的一個(gè)重要功能就是做數(shù)據(jù)庫(kù)連接。sqorn 利用 pg 這個(gè)庫(kù)實(shí)現(xiàn)了連接池、斷開(kāi)、查詢、事務(wù)的功能。
覆寫接口函數(shù)
內(nèi)核代碼想要具有拓展能力,暴露出一些接口讓 sqorn-xx 覆寫是很基本的。
context
內(nèi)核代碼中,最重要的就是 context 屬性,因?yàn)槿祟惲?xí)慣一步一步寫代碼,而最終生成的 query 語(yǔ)句是連貫的,所以這個(gè)上下文對(duì)象通過(guò) updateContext 存儲(chǔ)了每一條信息:
{
name: 'limit',
updateContext: (ctx, args) => {
ctx.lim = args
}
}
{
name: 'where',
updateContext: (ctx, args) => {
ctx.whr.push(args)
}
}
比如 Person.where({ name: 'bob' }) 就會(huì)調(diào)用 ctx.whr.push({ name: 'bob' }),因?yàn)?where 條件是個(gè)數(shù)組,因此這里用 push,而 limit 一般僅有一個(gè),所以 context 對(duì) lim 對(duì)象的存儲(chǔ)僅有一條。
其他操作諸如 where delete insert with from 都會(huì)類似轉(zhuǎn)化為 updateContext,最終更新到 context 中。
創(chuàng)建 builder
不用太關(guān)心下面的 sqorn-xx 包名細(xì)節(jié),這一節(jié)主要目的是說(shuō)明如何實(shí)現(xiàn) Demo 中的鏈?zhǔn)秸{(diào)用,至于哪個(gè)模塊放在哪并不重要(如果要自己造輪子就要仔細(xì)學(xué)習(xí)一下作者的命名方式)。
在 sqorn-core 代碼中創(chuàng)建了 builder 對(duì)象,將 sqorn-sql 中創(chuàng)建的 methods merge 到其中,因此我們可以使用 sq.where 這種語(yǔ)法。而為什么可以 sq.where().limit() 這樣連續(xù)調(diào)用呢?可以看下面的代碼:
for (const method of methods) {
// add function call methods
builder[name] = function(...args) {
return this.create({ name, args, prev: this.method });
};
}
這里將 where delete insert with from 等 methods merge 到 builder 對(duì)象中,且當(dāng)其執(zhí)行完后,通過(guò) this.create() 返回一個(gè)新 builder,從而完成了鏈?zhǔn)秸{(diào)用功能。
生成 query
上面三點(diǎn)講清楚了如何支持方言、用戶代碼內(nèi)容都收集到 context 中了,而且我們還創(chuàng)建了可以鏈?zhǔn)秸{(diào)用的 builder 對(duì)象方便用戶調(diào)用,那么只剩最后一步了,就是生成 query。
為了利用 context 生成 query,我們需要對(duì)每個(gè) key 編寫對(duì)應(yīng)的函數(shù)做處理,拿 limit 舉例:
export default ctx => {
if (!ctx.lim) return;
const txt = build(ctx, ctx.lim);
return txt && `limit ${txt}`;
};
從 context.lim 拿取 limit 配置,組合成 limit xxx 的字符串并返回就可以了。
build函數(shù)是個(gè)工具函數(shù),如果 ctx.lim 是個(gè)數(shù)組,就會(huì)用逗號(hào)拼接。
大部分操作比如 delete from having 都做這么簡(jiǎn)單的處理即可,但像 where 會(huì)相對(duì)復(fù)雜,因?yàn)閮?nèi)部包含了 condition 子語(yǔ)法,注意用 and 拼接即可。
最后是順序,也需要在代碼中確定:
export default {
sql: query(sql),
select: query(wth, select, from, where, group, having, order, limit, offset),
delete: query(wth, del, where, returning),
insert: query(wth, insert, value, returning),
update: query(wth, update, set, where, returning)
};
這個(gè)意思是,一個(gè) select 語(yǔ)句會(huì)通過(guò) wth, select, from, where, group, having, order, limit, offset 的順序調(diào)用處理函數(shù),返回的值就是最終的 query。
4 總結(jié)
通過(guò)源碼分析,可以看到制作一個(gè)這樣的庫(kù)有三個(gè)步驟:
- 創(chuàng)建 context 存儲(chǔ)結(jié)構(gòu)化 query 信息。
- 創(chuàng)建 builder 供用戶鏈?zhǔn)綍鴮懘a同時(shí)填充 context。
- 通過(guò)若干個(gè) SQL 子處理函數(shù)加上幾個(gè)主 statement 函數(shù)將其串聯(lián)起來(lái)生成最終 query。
最后在設(shè)計(jì)時(shí)考慮到 SQL 方言的話,可以將模塊拆成 核心、SQL、若干個(gè)方言庫(kù),方言庫(kù)基于核心庫(kù)做拓展即可。
5 更多討論
如果你想?yún)⑴c討論,請(qǐng)點(diǎn)擊這里,每周都有新的主題,周末或周一發(fā)布。