精讀《sqorn 原始碼》
前端精讀SQL%2520%25E7%25BC%2596%25E8%25AF%2591%25E5%2599%25A8%2520-%2520%25E8%25AF%258D%25E6%25B3%2595%25E5%2588%2586%25E6%259E%2590%25E3%2580%258B.md" rel="nofollow,noindex">《手寫 SQL 編譯器系列》 介紹瞭如何利用 SQL 生成語法樹,而還有一些庫的作用是根據語法樹生成 SQL 語句。
除此之外,還有一種庫,是根據程式語言生成 SQL。sqorn 就是一個這樣的庫。
可能有人會問,利用程式語言生成 SQL 有什麼意義?既沒有語法樹規範,也不如直接寫 SQL 通用。對,有利就有弊,這些庫不遵循語法樹,但利用簡化的物件模型快速生成 SQL,使得程式碼抽象程度得到了提高。而程式碼抽象程度得到提高,第一個好處就是易讀,第二個好處就是易操作。
資料庫特別容易抽象為面向物件模型,而對資料庫的操作語句 - SQL 是一種結構化查詢語句,只能描述一段一段的查詢,而面向物件模型卻適合描述一個整體,將資料庫多張表串聯起來。
舉個例子,利用typeorm
,我們可以用a
與b
兩個 Class 描述兩張表,同時利用ManyToMany
裝飾器分別修飾a
與b
的兩個欄位,將其建立起多對多的關聯
,而這個對映到 SQL 結構是三張表,還有一張是中間表ab
,以及查詢時涉及到的 left join 操作,而在 typeorm 中,一條find
語句就能連帶查詢處多對多關聯關係。
這就是這種利用程式語言生成 SQL 庫的價值,所以本週我們分析一下sqorn 這個庫的原始碼,看看利用物件模型生成 SQL 需要哪些步驟。
2 概述
我們先看一下 sqorn 的語法。
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 型別拆成不同分類的小包,這是因為不同資料庫支援的方言不同,sqorn 希望在語法上抹平資料庫間差異。
其次 sqorn 也是利用面向物件思維的,上面的例子通過sq`person`
生成了 Person 例項,實際上也對應了 person 表,然後Person`age < ${13}`
表示查詢:select * from person where age < 13
上面是利用 ES6 模板字串的功能實現的簡化 where 查詢功能,sqorn 主要還是利用一些函式完成 SQL 語句生成,比如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 原始碼,探討如何利用實現上面的功能。
3 精讀
我們從四個方面入手,講明白 sqorn 的原始碼是如何組織的,以及如何滿足上面功能的。
方言
為了實現各種 SQL 方言,需要在實現功能之前,將程式碼拆分為核心程式碼與拓展程式碼。
核心程式碼就是sqorn-sql
而拓展程式碼就是sqorn-pg
,拓展程式碼自身只要實現 pg 資料庫自身的特殊邏輯, 加上sqorn-sql
提供的核心能力,就能形成完整的 pg SQL 生成功能。
實現資料庫連線
sqorn 不但生成 query 語句,也會參與資料庫連線與執行,因此方言庫的一個重要功能就是做資料庫連線。sqorn 利用pg
這個庫實現了連線池、斷開、查詢、事務的功能。
覆寫介面函式
核心程式碼想要具有拓展能力,暴露出一些介面讓sqorn-xx
覆寫是很基本的。
context
核心程式碼中,最重要的就是 context 屬性,因為人類習慣一步一步寫程式碼,而最終生成的 query 語句是連貫的,所以這個上下文物件通過updateContext
儲存了每一條資訊:
{ name: 'limit', updateContext: (ctx, args) => { ctx.lim = args } } { name: 'where', updateContext: (ctx, args) => { ctx.whr.push(args) } } 複製程式碼
比如Person.where({ name: 'bob' })
就會呼叫ctx.whr.push({ name: 'bob' })
,因為 where 條件是個陣列,因此這裡用push
,而limit
一般僅有一個,所以 context 對lim
物件的儲存僅有一條。
其他操作諸如where
delete
insert
with
from
都會類似轉化為updateContext
,最終更新到 context 中。
建立 builder
不用太關心下面的sqorn-xx
包名細節,這一節主要目的是說明如何實現 Demo 中的鏈式呼叫,至於哪個模組放在哪並不重要(如果要自己造輪子就要仔細學習一下作者的命名方式)。
在sqorn-core
程式碼中建立了builder
物件,將sqorn-sql
中建立的methods
merge 到其中,因此我們可以使用sq.where
這種語法。而為什麼可以sq.where().limit()
這樣連續呼叫呢?可以看下面的程式碼:
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
物件中,且當其執行完後,通過this.create()
返回一個新builder
,從而完成了鏈式呼叫功能。
生成 query
上面三點講清楚瞭如何支援方言、使用者程式碼內容都收集到 context 中了,而且我們還建立了可以鏈式呼叫的builder
物件方便使用者呼叫,那麼只剩最後一步了,就是生成 query。
為了利用 context 生成 query,我們需要對每個 key 編寫對應的函式做處理,拿limit
舉例:
export default ctx => { if (!ctx.lim) return; const txt = build(ctx, ctx.lim); return txt && `limit ${txt}`; }; 複製程式碼
從context.lim
拿取limit
配置,組合成limit xxx
的字串並返回就可以了。
build
函式是個工具函式,如果 ctx.lim 是個陣列,就會用逗號拼接。
大部分操作比如delete
from
having
都做這麼簡單的處理即可,但像where
會相對複雜,因為內部包含了condition
子語法,注意用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) }; 複製程式碼
這個意思是,一個select
語句會通過wth, select, from, where, group, having, order, limit, offset
的順序呼叫處理函式,返回的值就是最終的 query。
4 總結
通過原始碼分析,可以看到製作一個這樣的庫有三個步驟:
- 建立 context 儲存結構化 query 資訊。
- 建立 builder 供使用者鏈式書寫程式碼同時填充 context。
- 通過若干個 SQL 子處理函式加上幾個主 statement 函式將其串聯起來生成最終 query。
最後在設計時考慮到 SQL 方言的話,可以將模組拆成 核心、SQL、若干個方言庫,方言庫基於核心庫做拓展即可。