精讀《手寫 SQL 編譯器 - 錯誤提示》

1 引言

編譯器除了生成語法樹以外,還要在輸入出現錯誤時給出恰當的提示。git

好比當用戶輸入 select (name,這是個未完成的 SQL 語句,咱們的目標是提示出這個語句未完成,並給出後續的建議: ) - + % / * . (github

2 精讀

分析一個 SQL 語句,現將 query 字符串轉成 Token 數組,再構造文法樹解析,那麼可能出現錯誤的狀況有兩種:sql

  1. 語句錯誤。
  2. 文法未完成。

給出錯誤提示的第一步是判斷錯誤發生。typescript

經過這張 Token 匹配過程圖能夠發現,當深度優先遍歷文法節點時,匹配成功後纔會返回父元素繼續往下走。而當走到父元素沒有根節點了纔算匹配成功;當嘗試 Chance 時沒有機會了,就是錯誤發生的時機。數組

因此咱們只要找到最後一個匹配成功的節點,再根據最後成功與否,以及搜索出下一個可能節點,就能知道錯誤類型以及給出建議了。函數

function onMatchNode(matchNode, store) {
  const matchResult = matchNode.run(store.scanner);

  if (!matchResult.match) {
    tryChances(matchNode, store);
  } else {
    const restTokenCount = store.scanner.getRestTokenCount();
    if (matchNode.matching.type !== "loose") {
      if (!lastMatch) {
        lastMatch = {
          matchNode,
          token: matchResult.token,
          restTokenCount
        };
      }
    }

    callParentNode(matchNode, store, matchResult.token);
  }
}

因此在運行語法分析器時,在遇到匹配節點(MatchNode)時,若是匹配成功,就記錄下這個節點,這樣咱們最終會找到最後一個匹配成功的節點:lastMatch優化

以後經過 findNextMatchNodes 函數找到下一個可能的推薦節點列表,做爲錯誤恢復的建議。spa

findNextMatchNodes 函數會根據某個節點,找出下一節點全部可能 Tokens 列表,這個函數後面文章再專門介紹,或者你也能夠先閱讀 源碼.

語句錯誤

也就是任何一個 Token 匹配失敗。好比:rest

select * from table_name as table1 error_string;

這裏 error_string 就是冗餘的語句。code

經過語法解析器分析,能夠獲得執行失敗的結果,而後經過 findNextMatchNodes 函數,咱們能夠獲得下面分析結果:

能夠看到,程序判斷出了 error_string 這個 Token 屬於錯誤類型,同時給出建議,能夠將 error_string 替換成這 14 個建議字符串中任意一個,都能使語句正確。

之因此失敗類型判斷爲錯誤類型,是由於查找了這個正確 Token table1 後面還有一個沒有被使用的 error_string,因此錯誤歸類是 wrong

注意,這裏給出的是下一個 Token 建議,而不是所有 Token 建議,所以推薦了 where 表示 「或者後面跟一個完整的 where 語句」。

文法未完成

和語句錯誤不一樣,這種錯誤全部輸入的單詞都是正確的,但卻沒有寫完。好比:

select *

經過語法解析器分析,能夠獲得執行失敗的結果,而後經過 findNextMatchNodes 函數,咱們能夠獲得下面分析結果:

能夠看到,程序判斷出了 * 這個 Token 屬於未完成的錯誤類型,建議在後面補全這 14 個建議字符串中任意一個。比較容易聯想到的是 where,但也能夠是任意子文法的未完成狀態,好比後面補充 , 繼續填寫字段,或者直接跟一個單詞表示別名,或者先輸入 as 再跟別名。

之因此失敗類型判斷爲未完成,是由於最後一個正確 Token * 以後沒有 Token 了,但語句解析失敗,那只有一個緣由,就是語句爲寫完,所以錯誤歸類是 inComplete

找到最易讀的錯誤類型

在一開始有提到,咱們只要找到最後一個匹配成功的節點,就能夠順藤摸瓜找到錯誤緣由以及提示,但最後一個成功的節點可能和咱們人類直覺相違背。舉下面這個例子:

select a from b where a = '1' ~ -- 這裏手滑了

正常狀況,咱們都認爲錯誤點在 ~,而最後一個正確輸入是 '1'。但詞法解析器可不這麼想,在我第一版代碼裏,判斷出錯誤是這樣的:

提示是 where 錯了,並且提示是 .,有點摸不着頭腦。

讀者可能已經想到了,這個問題與文法結構有關,咱們看 fromClause 的文法描述:

const fromClause = () =>
  chain(
    "from",
    tableSources,
    optional(whereStatement),
    optional(groupByStatement),
    optional(havingStatement)
  )();

雖然實際傳入的 where 語句多了一個 ~ 符號,但因爲文法認爲整個 whereStatement 是可選的,所以出錯後會跳出,跳到 b 的位置繼續匹配,而 顯然 groupByStatementhavingStatement 都不能匹配到 where,所以編譯器認爲 「不會從 b where a = '1' ~」 開始就有問題吧?所以繼續往回追溯,從 tableName 開始匹配:

const tableName = () =>
  chain([matchWord, chain(matchWord, ".", matchWord)()])();

此時第一次走的 b where a = '1' ~ 路線對應 matchWord,所以嘗試第二條路線,因此認爲 where 應該換成 .

要解決這個問題,首先要 認可這個判斷是對的,由於這是一種 錯誤提早的狀況,只是人類理解時每每只能看到最後幾步,因此咱們默認用戶想要的錯誤信息,是 正確匹配鏈路最長的那條,並對 onMatchNode 做出下面優化:

lastMatch 對象改成 lastMatchUnderShortestRestToken:

if (
  !lastMatchUnderShortestRestToken ||
  (lastMatchUnderShortestRestToken &&
    lastMatchUnderShortestRestToken.restTokenCount > restTokenCount)
) {
  lastMatchUnderShortestRestToken = {
    matchNode,
    token: matchResult.token,
    restTokenCount
  };
}

也就是每次匹配到正確字符,都獲取剩餘 Token 數量,只保留最後一匹配正確 且剩餘 Token 最少的那個

3 總結

作語法解析器錯誤提示功能時,再次刷新了筆者三觀,原來咱們覺得的必然,在編譯器裏對應着那麼多 「可能」。

當咱們遇到一個錯誤 SQL 時,錯誤緣由每每不止一個,你能夠隨便截取一段,說是從這一步開始就錯了。語法解析器爲了讓報錯符合人們的第一直覺,對錯誤信息作了 過濾,只保留剩餘 Token 數最短的那條錯誤信息。

4 更多討論

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

若是你想參與討論,請點擊這裏,每週都有新的主題,週末或週一發佈。

相關文章
相關標籤/搜索