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

1 引言

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

通過連續幾期的介紹,《手寫 SQL 編譯器》系列進入了 「智能提示」 模塊,前幾期從 詞法到文法、語法,再到構造語法樹,錯誤提示等等,都是爲 「智能提示」 作準備。git

因爲智能提示須要對詞法分析、語法分析作深度定製,因此咱們沒有使用 antlr4 等語法分析器生成工具,而是創造了一個 JS 版語法分析生成器 syntax-parsergithub

此次一口氣講完如何從 syntax-parser 到作一個具備智能提示功能的 SQL 編輯器。算法

2 精讀

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

爲了更清晰展示邏輯層次,同時知足解耦的要求,筆者先從智能提示總體設計架構講起。typescript

智能提示的架構

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

  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

若是你想參與討論,請點擊這裏,每週都有新的主題,週末或週一發佈。前端精讀 - 幫你篩選靠譜的內容。

相關文章
相關標籤/搜索