OCamlでSQL formatter を作ってみた
台風だったり暑さだったり、気象の振れ幅が大きくなってきてるなー、と思ったりしてます。でも古気候学とか噛じってると、さらに昔はもっと激烈だったんだろうなぁ、と思ったり思わなかったり。 さて、題名の通りなんですが、やっとこさ形になったのでこの記事を書こうと思います。 モチベーション https://github.com/derui/ocaml-sql-format Initial commitが5/31なので、大体この記事を書いている段階だと三ヶ月弱経過した形になります。まずなんで作ろうと思ったのかというと、とてもシンプルです。 そういえばFormatterって作ったことなかった 最近OCaml書いてないから書きたかった SQL formatterってあんまり見ない(ような気がしたけど探したら結構あった)のでやってみよう ついでに仕事でも使うし という感じで決定しました。大抵SQLのフォーマットって、ググって出てきたWeb toolに放り込んで…というのが多いですよね。最近のChatGPTだったりで、入力したものが学習されたり漏れうる、と考えると、あまりやりたくないところです。今の会社だとバリバリ個人情報を調べたりするんで、そのフォーマットごときでリスクを負いたくもないですし。モチベーションはそんな感じなので、どういう感じで作っていったかを思い出しながら書いていこうかと思います。 lexer/parserライブラリ 今回は当然OCamlを使ったのですが、SQLをパースするというのが一番の力仕事になることはわかりきっていました。また、最近はUnicodeがデフォルトですし(マレに絵文字を検索せんとあかんこともある)、スタンダードなocamllexよりは、よりモダンなものを使ってみたいものです。 Sedlex - https://github.com/ocaml-community/sedlex ということで、今回はSedlexを利用してみました。前々から使おうかなーと思ってたんですが、機会がなかったのでちょうどよかったです。Sedlexは、ppxを存分に利用したocamllexの代替として利用できるlexer generatorとなってます。特徴は↑を見てもらった方が早いので、こんな感じに使えるよ?というのを示そうかと let letter = [%sedlex.regexp? 'a' .. 'z' | 'A' .. 'Z' | 0x0153 .. 0xfffd] let rec token buf = match%sedlex buf with | letter -> Letter (Sedlexing.Utf8.lexeme buf) | eof -> Eof ocamllexで地味にペインだったのが、tuaregとかだと完全にはサポートされきってないので、フォーマットだったりなんだったりが微妙になったり補完がききづらかったり、という問題がありました。 sedlexはppxベースなので、すべて完全なOCamlソースとして扱える、というのが利点ですね。拡張の中だと補完は効かないんですが、まぁエラーはわかりやすいです。 parser generatorとしてはmenhir一択です。現状ocamlyaccを選択する理由はございませんので、もしocamlyaccしか使ったことがない方は使ってみることをオススメします。 テストと自動生成の仕組み formatterという決定論的なツールを作るのと、フォーマット前後の結果が重要なツールなので、テストも最初から考えとくことにしました。とはいえ、相性が最もよいのはexpectation testであることは想像がついていたので、ppx_expectを利用してます。 が!それだけだととてもめんどくさいです。現状100ファイルくらいあるのですが、全部に同じようなことを書いていくのは正直やってられないです。また、SQLはキーワードの数が正気か?ってくらい多いので、それに対応するlexer generatorの設定も死ぬほど多いです。これらを全部手書きしていたら時間がいくらあっても足りないので、いくつか自動生成ツールを作成しました。 キーワードからsedlexのフォーマットに変換する ast/printer/parserのデフォルトテンプレートを作成する SQLからのテストの生成 これらがあることで、大体一個のsyntaxを作成するのに小さければ5分〜10分、大きいものでも30分程度で量産できるようになりました。テストも、SQLを変更して再生成→promoteという流れを作ることができました。予想通りexpectation testはバチっと嵌ったので、こういう系統ではオススメです。 SQLのパースの苦しみ さて、一番苦しんだのはSQLをパースするためのgrammerを記述するところです。今回参考にしたのは三つあります。 https://ronsavage.github.io/SQL/sql-2003-2.bnf.html#data%20type http://teiid.github.io/teiid-documents/13.1.x/content/reference/r_bnf-for-sql-grammar.html#parseBasicDataType https://www.sqlite.org/lang.html 上から、 ISOにおける定義 、 teiidというツールにおけるBNF 、 SQLiteにおけるsyntax diagram となってます。最終的にはsqliteのを基本にしつつ、teiidを参考に、ISOのものを標準との比較資料として使いました。 パースは合計3回書き直しています。最初はISOのものを参考にしてたのですが、超絶な分量(約1000P)かつ、LRではそのまま記述できない再帰や、括弧が曖昧になってしまい解決できないケースが存在するっぽく、私の知識ではどうにかできませんでした…。 teiidのものはもうちょっとシンプルになっているのですが、同様に式における括弧が曖昧になってしまうようでした。括弧については、LRでは非常にやりづらいもので、if-then-elseと同様に解決しづらいものとして扱われています。 最終的にsqliteのものを利用したのは、必要十分な量、かつダイアグラム上曖昧になる部分が少ない、というところで参考にしてます。一部そのままだと書き下せない部分があったので、そこはteiidのものを利用してます。 SQLiteはシンプルかつ十分な性能…的な立ち位置なので、merge文などは実装されていません。個人的にもmerge文を使うケースはそんな無かったので、一旦ここはスキップしてます。 menhirのnew syntax 若干話は逸れますが、menhirにはold syntaxとnew syntaxってものがあります。 ...