1. 程式人生 > >精讀《手寫 SQL 編譯器 - 智慧提示》

精讀《手寫 SQL 編譯器 - 智慧提示》

1 引言

詞法、語法、語義分析概念都屬於編譯原理的前端領域,而這次的目的是做 具備完善語法提示的 SQL 編輯器,只需用到編譯原理的前端部分。

經過連續幾期的介紹,《手寫 SQL 編譯器》系列進入了 “智慧提示” 模組,前幾期從 詞法到文法、語法,再到構造語法樹,錯誤提示等等,都是為 “智慧提示” 做準備。

由於智慧提示需要對詞法分析、語法分析做深度定製,所以我們沒有使用 antlr4 等語法分析器生成工具,而是創造了一個 JS 版語法分析生成器 syntax-parser

這次一口氣講完如何從 syntax-parser 到做一個具有智慧提示功能的 SQL 編輯器。

2 精讀

從語法解析、智慧提示和 SQL 編輯器封裝三個層次來介紹,這三個層次就像俄羅斯套娃一樣具有層層遞進的關係。

為了更清晰展現邏輯層次,同時滿足解耦的要求,筆者先從智慧提示整體設計架構講起。

智慧提示的架構

syntax-parser 是一個 JS 版的語法分析器生成器,除了類似 antlr4 基本語法分析功能外,還支援專門為智慧提示優化的功能,後面會詳細介紹。整體架構設計如下圖所示:

  1. 首先需要實現 SQL 語法,我們利用語法分析器生成器 syntax-parser,生成一個 SQL 語法分析器,這一步其實是利用 syntax-parser 能力完成了 sql lexersql parser
  2. 為了解析語法樹含義,我們需要在 sql parser 基礎之上編寫一套 sql reader
    ,包含了一些分析函式解析語法樹的語義。
  3. 利用 monaco-editor 生態,利用 sql reader 封裝 monaco-editor 外掛,同時實現 使用者 <=> 編輯器 間的互動,與 編輯器 <=> 語義分析器 間的互動。

語法解析器

syntax-parser 分為詞法分析、語法分析兩步。詞法分析主要利用正則構造一個有窮自動機,大家都學過的 “編譯原理” 裡有更完整的解讀,或者移步 精讀《手寫 SQL 編譯器 - 詞法分析》,這裡主要介紹語法分析。

詞法分析的輸入是語法分析輸出的 Tokens。Tokens 就是一個個單詞,Token 結構儲存了單詞的值、位置、型別。

我們需要構造一個執行鏈條消費這些 Token,也就是可以執行文法掃描的程式。我們用四種類型節點描述文法,如下圖所示:

如果不瞭解文法概念,可以閱讀 精讀《手寫 SQL 編譯器 - 文法介紹》

能消耗 Token 的只有 MatchNode 節點,ChainNode 節點描述先後關係(比如 expr -> name id),TreeNode 節點描述並列關係(比如 factor -> num | id),FunctionNode 是函式節點,表示還未展開的節點(如果把文法匹配比做迷宮探險,那這是個無限迷宮,無法窮盡展開)。

如何用 syntax-parser 描述一個文法,可以訪問文件,現在我們已經描述了一個文法樹,應該如何解析呢?

我們先找到一個非終結符作為根節點,深度遍歷所有非終結符節點,遇到 MatchNode 時如果匹配,就消耗一個 Token 並繼續前進,否則文法匹配失敗。

遇到 ChainNode 會按照順序執行其子節點;遇到 FunctionNode(非終結符節點)會執行這個函式,轉換為一個非 FunctionNode 節點,如下圖所示:

遇到 TreeNode 節點時儲存這個節點執行狀態並繼續執行,在 MatchNode 匹配失敗時可以還原到此節點繼續嘗試下個節點,如下圖所示:

這樣就具備了最基本的語法分析功能,如需更詳細閱讀,可以移步 精讀《手寫 SQL 編譯器 - 語法分析》

我們還做了一些優化,比如 First 集優化與路徑快取優化。限於篇幅,分佈在以下幾篇文章:

SQL 編輯器重點在於如何做輸入提示,也就是如何在使用者游標位置給出恰當的提示。這就是我們定製 SQL 編輯器的原因,輸入提示與語法檢測需要分開來做,而語法樹並不能很好解決輸入提示的問題。

智慧提示

為了找到一個較為完美的語法提示方案,通過查閱大量資料,我決定將游標作為一個 Token 考慮來實現智慧提示。

思考

我們用 | 表示游標所在位置,那麼下面的 SQL 應該如何處理?

select | from b;
複製程式碼
  • 從語法角度來看,它是錯的,因為實際上是一個不完整語句 "select from b;"
  • 從提示角度來看,它是對的,因為這是一個正確的輸入過程,游標位置再輸入一個單詞就正確了。

你會發現,從語法和提示角度來看同一個輸入,結果往往是矛盾的,所以我們需要分兩條執行緒分別處理語法與提示。

但輸入錯誤時,我們是無法構造語法樹的,而智慧提示的時機往往都是語句語法錯誤的時機,用過 AST 工具的人都知道。可是沒有語法樹,我們怎麼做到智慧的提示呢?試想如下語句:

select c.| from (
  select * from dt;
) c;
複製程式碼

面對上面這個語句,很顯然 c. 沒有寫完,一般的語法樹解析器提示你語法錯誤。你可能想到這幾種方案:

  1. 字串匹配方式強行提示。但很顯然這樣提示不準確,沒有完整語法樹,是無法做精確解析的。而且當語法複雜時,字串解析方案几乎無從下手。
  2. 把游標位置用一個特殊的字串補上,先構造一個臨時正確的語句,生成 AST 後再找到游標位置。

一般我們會採取第二種方案,看上去相對靠譜。處理過程是這樣的:

select c.$my_custom_symbol$ from ...
複製程式碼

之後在 AST 中找到 $my_custom_symbol$ 字串,對應的節點就是游標位置。實際上這可以解決大部分問題,除了關鍵字。

這種方案唯有關鍵字場景不相容,試想一下:

select a |from b;
# select a $my_custom_symbol$ b;
複製程式碼

你會發現,“補全游標文字” 法,在關鍵字位置時,會把原本正確的語句變成錯誤的語句,根本解析不出語法樹。

我們在 syntax-parser 解析引擎層就解決了這個問題,解決方案是 連同游標位置一起解析。

兩個假設

我們做兩個基本假設:

  1. 需要自動補全的位置分為 “關鍵字” 與 “非關鍵字”。
  2. “非關鍵字” 位置基本都是由字串構成的。

關鍵字:

因此針對第一種假設,syntax-parser 內建了 “關鍵字提示” 功能。因為 syntax-parser 可以拿到你配置的文法,因此當給定游標位置時,可以拿到當前位置前一個 Token,通過回溯和平行嘗試,將後面所有可能性提示出來,如下圖:

輸入是 select a |,灰色部分是已經匹配成功的部分,而我們發現游標位置前一個 Token 正是紅色標識的 word,通過嘗試執行推導,我們發現,桔紅色標記的 ',''from' 都是 word 可能的下一個確定單詞,這種單詞就是 SQL 語法中的 “關鍵字”,syntax-parser 會自動告訴你,游標位置可能的輸入是 [',', 'from']

所以關鍵字的提示已經在 syntax-parser 層內建解決了!而且無論語法正確與否,都不影響提示結果,因為演算法是 “尋找游標位置前一個 Token 所有可能的下一個 Token”,這可以完全由詞法分析器內建支援。

非關鍵字:

針對非關鍵字,我們解決方案和用特殊字串補充類似,但也有不同:

  1. 在游標位置插入一個新 Token,這個 Token 型別是特殊的 “游標型別”。
  2. 在 word 解析函式加一個特殊判斷,如果讀到 “游標型別” Token,也算成功解析,且消耗 Token。

因此 syntax-parser 總是返回兩個 AST 資訊:

{
  "ast": {},
  "cursorPath": []
}
複製程式碼

分別是語法樹詳細資訊,與游標位置在語法樹中的訪問路徑。

對於 select a | 的情況,會生成三個 Tokens:['select', 'a', 'cursor'],對於 select a| 的情況,會生成兩個 Tokens:['select', 'a'],也就是游標與字元相連時,不會覆蓋這個字元。

cursorPath 的生成也比 “字串補充” 方案更健壯,syntax-parser 生成的 AST 會記錄每一個 Token 的位置,最終會根據游標位置進行比對,進而找到游標對應語法樹上哪個節點。

對 .| 的處理:

可能你已經想到了,.| 情況是很通用的輸入場景,比如 user. 希望提示出 user 物件的成員函式,或者 SQL 語句表名存在專案空間的情況,可能 tableName 會存在 .| 的語法。

.| 狀況時,語法是錯誤的,此時智慧提示會遇到挑戰。根據查閱的資料,這塊也有兩種常見處理手法:

  1. . 位置加上特殊標識,讓語法解析器可以正確解析出語法樹。
  2. 抹去 .,先讓語法正確解析,再分析語法樹拿到 . 前面 Token 的屬性,推匯出後面的屬性。

然而這兩種方式都不太優雅,syntax-parser 選擇了第三種方式:隔空打牛。

通過抽象,我們發現,無論是 user.name 還是 udf:count() 這種語法,都要求在某個制定字元打出時(比如 .:),提示到這個字元後面跟著的 Token。

此時游標焦點在 . 而非之後的字元上,**那我們何不將游標偷偷移到 . 之後,進行空游標 Token 補位呢!**這樣不但能完全複用之前的處理思想,還可以拿到我們真正想拿到的位置:

select a(.|) from b;
# select a. (|) from b
複製程式碼

對比後發現,第一行擁有 4 個 Token,語法錯誤,而經過修改的第二行擁有 5 個 Token(一個游標補位),語法正確,且游標所在位置等價於第一行我們希望提示的位置,此問題得以解決。

SQL 編輯器封裝

我們擁有了內建 “智慧提示” 功能的語法解析器,定製了一套自定義的 SQL 詞法、文法描述,便完成了 sql-lexersql-parser 這一層。由於 SQL 文法完善工作非常龐大,且需要持續推進,這裡舉流計算中,申明動態維表的例子:

CREATE TABLE dwd_log_pv_wl_ri(
  PRIMARY KEY(rowkey),
  PERIOD FOR SYSTEM_TIME
) WITH ()
複製程式碼

要支援這種語法,我們在非終結符 tableOption 下增加兩個分支即可:

const tableOption = () =>
  chain([
    chain(stringOrWord, dataType)(),
    chain("primary", "key", "(", primaryKeyList, ")")(),
    chain("period", "for", "system_time")()
  ])();
複製程式碼

sql-reader:

為了方便解析 SQL 語法樹,我們在 sql-reader 內建了幾個常用方法,比如:

  • 找到距離游標位置最近的父節點。比如 select a, b, | from d 會找到這個 selectStatement
  • 根據表源找到所有提供的欄位。表源是指 from 之後跟的語法,不但要考慮巢狀場景,別名,分組,方言,還要追溯每個欄位來源於哪張表(針對 join 或 union 的情況)。

有了 sql-reader,我們可以保證在這種層層巢狀 + 別名混淆 + select * 這種複雜的場景下,仍然能追溯到欄位的最原始名稱,最原始的表名:

這樣上層業務拓展時,可以拿到足夠準、足夠多的資訊,具有足夠好的拓展型。

monaco-editor plugin:

我們也支援了更上層的封裝,Monaco Editor 外掛級別的,只需要填一些引數:獲取表名、獲取欄位的回撥函式就能 Work,統一了內部業務的呼叫方式:

import { monacoSqlAutocomplete } from '@alife/monaco-sql-plugin';

// Get monaco and editor.

monacoSqlAutocomplete(monaco, editor, {
  onInputTableField: async tableName => { // ...},
  onInputTableName: async () => { // ... },
  onInputFunctionName: async () => { // ... },
  onHoverTableName: async cursorInfo => { // ... },
  onHoverTableField: (fieldName, extra) => { // ... },
  onHoverFunctionName: functionName => { // ... }
});
複製程式碼

比如實現了 onInputTableField 介面,我們可以拿到當前表名資訊,輕鬆實現欄位提示:

你也許會看到,上圖中滑鼠位置有錯誤提示(紅色波浪線),但依然給出了正確的推薦提示。這得益於我們對 syntax-parser 內部機制的優化,將語法檢查與智慧提示分為兩個模組獨立處理,經過語法解析,雖然丟擲了語法錯誤,但因為有了游標的加入,最終生成了語法樹。

再比如實現了 onHoverFunctionName,可以自定義滑鼠 hover 在函式時的提示資訊:

得益於 sql-reader,我們對 sql 語句做了層層解析,所以才能把自動提示做到極致。比如在做欄位自動提示時,經歷瞭如下判斷步驟:

而你只需要實現 onInputTableField,告訴程式每個表可以提供哪些欄位,整個流程就會嚴格的層層檢查表名提供對原始欄位與 selectList 描述的輸出欄位,找到對映關係並逐級傳遞、校驗,最終 Merge 後一直冒泡到當前游標位置所在語句,形成輸入建議。

4 總結

整個智慧提示的封裝鏈條如下:

syntax-parser -> sql-parser -> monaco-editor-plugin

對應關係是:

語法解析器生成器 -> SQL 語法解析器 -> 編輯器外掛

這樣邏輯層次清晰,解耦,而且可以從任意節點切入,進行自定義,比如:

從 syntax-parser 開始使用

從最底層開始使用,也許有兩個目的:

  1. 上層封裝的 sql-parser 不夠好用,我重寫一個 sql-parser' 以及 monaco-editor-plugin'。
  2. 我的場景不是 SQL,而是流程圖語法、或 Markdown 語法的自動提示。

針對這種情況,首先將目標文法找到,轉化成 syntax-parser 的語法,比如:

chain(word, "=>", word);
複製程式碼

再仿照 sql-parser -> monaco-editor-plugin 的結構把上層封裝依次實現。

從 sql-parser 開始使用

也許你需要的僅僅是一顆 SQL 語法樹?或者你的輸出目標不是 SQL 編輯器而是一個 UI 介面?那可以試試直接使用 sql-parser。

sql-parser 不僅可以生成語法樹,還能找到當前游標位置所在語法樹的節點,找到 SQL 某個語法返回的所有欄位列表等功能,基於它,甚至可以做 UI 與 SQL 文字互轉的應用。

從 monaco-editor-plugin 開始使用

也許你需要支援自動提示的 SQL 編輯器,那太棒了,直接用 monaco-editor-plugin 吧,根據你的業務場景或個人喜好,實現一個定製的 monaco-editor 互動外掛。

目前我們只開源最底層的 syntax-parser,這也是業務無關的語法解析引擎生成器,期待您的使用與建議!

討論地址是:精讀《手寫 SQL 編譯器 - 智慧提示》 · Issue #118 · dt-fe/weekly

如果你想參與討論,請點選這裡,每週都有新的主題,週末或週一釋出。前端精讀 - 幫你篩選靠譜的內容。