こちらはPostgreSQL Advent Calendar 2022 カレンダー2枚目・15日目の投稿となります。
前回は、@hmatsu47さんのSupabase で TCE(透過的列暗号化)を軽く試してみたでした。
はじめに
こんにちは、フューチャーでアルバイトをしている齋藤です。以前は同社のインターンでSQLフォーマッタを作成していました(記事)。現在はインターン中に作成していたSQLフォーマッタをPostgreSQLの構文に対応させる作業に取り組んでいます。
このフォーマッタではSQLパーサにtree-sitter-sqlを利用していますが、対応していない構文がいくつか存在します。本記事では、未対応の構文であるBETWEEN述語を例に、tree-sitterの構文拡張の手順を紹介します。開発中のSQLフォーマッタはOSS公開予定ですので、ぜひ仲間を増やしたいという思いから記事にしました。
また、現在作成中のフォーマッタのVSCode拡張機能化にも取り組んでいます。ぜひそちらも併せてご覧ください!
VSCode拡張機能化に関する記事:
- Language Server Protocolを用いたVSCode拡張機能開発 (前編) | フューチャー技術ブログ
- Language Server Protocolを用いたVSCode拡張機能開発 (後編) | フューチャー技術ブログ
アウトライン
本記事のアウトラインは以下の通りです。
- tree-sitter、tree-sitter-sqlについて
- tree-sitterの構文拡張用の環境構築
- 構文木を出力するプログラムの実装
- 構文についての説明
- BETWEEN述語の規則を追加
tree-sitter
tree-sitterは文法からパーサ(構文解析器)を自動生成するパーサジェネレータツールであり、生成されたパーサで構文解析を行うライブラリでもあります。特徴として、一般的なパーサライブラリでは抽象構文木(AST)を構築するのに対し、tree-sitterで生成されたパーサは具象構文木(CST)を構築するという点があげられます。CSTについてはインターンの記事で取り上げています。
構築されるCSTにはコメントトークンも含まれてるため、シンタックスハイライトに用いられているようです。
参考:
tree-sitter-sql
tree-sitter-sqlはtree-sitter用に書かれたSQLの文法とその文法によって生成されたパーサライブラリです。SQLの中でも、PostgreSQLにフォーカスしていたようです。インターンで作成したフォーマッタは、このライブラリによる構文解析結果をもちいてSQLのフォーマットを行っています。
しかし、BETWEEN述語やUNION
、INTERSECT
などの結合演算など、基本的な構文であるにもかかわらず、対応していない構文が存在します。本記事では、その中でもBETWEEN述語に対応させるための構文拡張を行います。
環境構築
まず、tree-sitterの構文拡張のために行った環境構築について説明します。
tree-sitter-cliのインストール
tree-sitterでパーサを生成するために、tree-sitter-cliをインストールします(参考Tree-sitter | Creating Parser)。また、tree-sitterによるパーサを開発するためには、Node.jsとCコンパイラが必要です。今回使用したバージョンは以下の通りです。
tools | バージョン |
---|---|
node | 16.17.1 |
gcc | 12.2.0 |
tree-sitter | 0.20.7 |
tree-sitter-sqlのインストール
tree-sitter-sqlをcloneします。tree-sitter用のSQL構文はいくつかありますが、今回は最もスター数が多いものを選択しました。
git clone https://github.com/m-novikov/tree-sitter-sql.git |
git clone
を行うと、以下のようなエラーが発生する場合があります。
error: unable to create file [filepath]: Filename too long |
これはファイル名が長すぎることが問題であるようなので、以下の設定を行うことで解決します。
git config --global core.longpaths true |
git clone
したtree-sitter-sqlのルートディレクトリで、tree-sitter test
コマンドでテストが動作したら環境構築終了です。
cd ./tree-sitter-sql |
構文解析例
実際にパースしてみましょう。以下のファイルを用意します。
SELECT |
tree-sitter parse
コマンドで、ソースファイルをパースできます。
$ tree-sitter parse ./exapmles/simple.sql |
CSTの出力について
上述したtree-sitter parse
により出力される結果では、ノードのラベルのみ表示されており、識別子やキーワードなどが表示されません。そこで、パース結果からCSTを出力する処理を自作しました。
言語にはRustを使用します。
準備
tree-sitter-sql
の結果を利用してCSTを出力するためのプロジェクトを作成します。
cargo new print-cst |
Cargo.toml
に次の依存関係を追加します。
[dependencies] |
また、Github上のtree-sitter-sqlが使用しているtree-sitterのバージョンが古い(2022年11月22日現在)ため、tree-sitter-cliとtree-sitterのバージョン不整合が生じる可能性があります。バージョン不整合が生じるとき、後述するプログラムを実行すると以下のような実行時エラーが発生します。
thread 'main' panicked at 'called `Result::unwrap()` on an `Err` value: LanguageError { version: 14 }', src\lib.rs:16:35 |
この場合、tree-sitter-sqlのCargo.toml
も修正する必要があります。
[dependencies] |
実装
main.rs
に次のように実装しました。
use std::fs::read_to_string; |
実行例
作成したプログラムを用いて、実際にCSTを表示してみましょう。
SELECT |
$ cargo run ./examples/simple.sql |
ノードに対応する文字列とキーワードを出力できました。
構文例
次に、tree-sitter用の構文について簡単に紹介します。
tree-sitter では文法を grammar.js
に記述します。clone した tree-sitter-sql のルートディレクトリにある grammar.js
を編集していきます。ここではDSL(ドメイン固有言語)について細かくは説明しないので、詳しく知りたい方はtree-sitterのドキュメントを参照してください。
規則
例えば、tree-sitter-sql で WHERE句は以下のように記述されています(where_clauseの定義)。
where_clause: $ => seq(kw("WHERE"), $._expression) |
seq
はtree-sitterの文法のDSLの1つで、複数の規則を連結できます。上の例では、kw("WHERE")
のあとに$._expression
が現れることを示しています。
kw
関数はtree-sitter-sqlのgrammar.js
で定義されている関数で、キーワード(k
eyw
ord)が大文字か小文字であるかを考慮しなくするなどの処理を行います。パース時には、where
やWHERE
というキーワードとマッチします(kw関数の定義)。
アンダースコアから始まる規則
規則名の先頭の文字をアンダースコアから始めることで、生成されるCSTにノードとして出現させないように設定できます(ドキュメント)。例えば、算術演算や識別子、リテラルなどの式は_expression
という名前で以下のように定義されています。
_expression: $ => |
choice
はtree-sitterのDSLで、引数のうちいずれか1つとマッチすることを意味しています。つまり、この規則は、文字列やTRUE
、FALSE
など各式に対応した規則を呼び出し、いずれか1つとマッチすることになります。つまり、ソースファイル中に式が現れるたびに_expression
が呼び出されています。これがCST上に現れると、例えば1+2-3
という式のパース結果が以下のようになってしまいます。
(_expression |
アンダースコアから始めることで、CST上に現れないように設定でき、以下のようにシンプルな木にできます。
(binary_expression |
優先度、結合性
ここで詳細は述べませんが、tree-sitterは明示しない場合、曖昧な文法を扱うことができません(参考)。
例えば、以下のような論理式を考えてみます。
NOT X AND Y OR Z |
この式はどのように解釈されるでしょうか?NOT (X AND (Y OR Z))
や(NOT X) AND (Y OR Z)
、((NOT X) AND Y) OR Z
など、複数通りに解釈できてしまうと思います。このように、複数通りの解釈ができてしまうような文法を曖昧な文法といい、そのままではパースできません。
これは、優先度・結合性を文法に記述することで対処できます。tree-sitter-sqlでは優先度をJavaScriptの定数として以下のように定義しています。
const PREC = { |
これを用いて、論理式に優先度・結合性を加えて記述した規則は次のようになります。
boolean_expression: $ => |
優先度は、NOT > AND > OR
になっています。優先度が高いものほど優先して結合されるため、上述の論理式をtree-sitter-sqlでパースすると、((NOT X) AND Y) OR Z
と解釈されます。なお、prec.left
は左結合であることを意味しています。
extras
ファイルのどこに現れてもよい規則をextrasで記述できます。
これを使って、コメントや空白、改行を簡単に記述できます(コメント、空白の定義)が、CST上では直感的でない場所位置に現れる場合もあります(インターンの記事後編参照)。
BETWEEN述語への対応
現状のtree-sitter-sqlを使用して、BETWEEN
を含むSQLをパースできるか確認してみましょう。以下のようなファイルを用意します。
SELECT |
$ tree-sitter parse .\examples\between.sql |
構文エラーが発生し、WHERE句内のBETWEEN述語には対応していないことがわかります。grammar.jsを見てみるとBETWEENというキーワードはWINDOW関数のFRAME句にしか想定していないため、BETWEENがERRORノードと扱われているようです。
規則の追加
BETWEEN述語に対応する規則がそもそも存在していないことがわかったため、文法を拡張することで対応していきます。
BETWEEN述語は次のような構文になっています。PostgreSQLのドキュメントでは構文について詳しく書かれていなかったので、Oracle SQLのドキュメントを参考にしました。
(expression) (NOT)? BETWEEN (expression) AND (expression) |
なお、(NOT)?
は正規表現で使われる ?
と同じ意味で、 NOT
が0回または1回現れることを表現しています。tree-sitterの構文では、optional
というDSLで表現されます。
率直にDSLに直すと、次のような規則が書けます。
between_and_expression: $ => |
この規則をSQLの式に対応する規則_expression
に追加します。
_expression: $ => |
これでBETWEEN述語の規則を追加できました。拡張した文法をもとにパーサを生成してみましょう。以下のコマンドを実行します。
$ tree-sitter generate |
エラーが発生してしまい、パーサが生成できませんでした。これは、上述した規則では優先度を記述していないため、文法が曖昧になってしまっていることが原因です。例えば、X BETWEEN Y AND Z AND W
のAND
がBETWEEN述語のものなのか、論理式のものなのかをパーサが自動で判別できません。つまり、X BETWEEN (Y AND Z) AND W
や(X BETWEEN Y AND Z) AND W
など、複数の解釈ができてしまいます。
そこで、優先度と結合性を追加します。
between_and_expression: $ => |
prec.left
は左結合であることを示し、PREC.comparative
で比較演算子と同じ優先度であることを指定しています。比較演算子はAND
よりも高い優先度であるため、X BETWEEN Y AND Z AND W
は(X BETWEEN Y AND Z) AND W
と解釈されます。
動作確認
次のファイルをパースしてみましょう。
SELECT |
以下のコマンドでパーサを生成します。
tree-sitter generate |
先ほど作成した print-cst
を用いて、パース結果を出力します。
$ cd [print-cstのパス] |
これでBETWEEN
を含むSQLがパースできるようになりました!
テストの追加
最後に、今回追加したBETWEEN述語の拡張をtree-sitter test
(Tree-sitter|Creating Parsers)でテストできるようにしましょう。
test/corpus/between.txt
を作成して、以下のように記述します。
======================================= |
=
で囲まれた行にテスト名を書きます- 次に、入力として与えるソースコードを記述し、下に
---
を記述します - 最後に期待する結果をS式で記述します
tree-sitter test
でテストを行います。-f
フラグを加えることで、特定のテストのみを実行できます。
$ tree-sitter test -f 'BETWEEN predicates' |
まとめ
本記事では、tree-sitter-sqlでBETWEEN述語を扱えるように構文拡張を行いました。tree-sitter用のSQL構文はまだまだ未完成なので、皆さんも一緒によりよいパーサを作ってみませんか?