概要
こちらの「分析SQLスタイルガイド」を拝見し、SQLのフォーマッタには需要があると感じて早速作り始めてみた。とりあえず動くようになったので、これまでの一部始終を記事にする。具体的には、以下の内容を扱う。
- Prettierという既存のフォーマッタをコマンドやエディタから利用する方法
- Prettierのプラグインとして任意の言語のフォーマッタを実装する方法(分析SQLを題材にする)
普段から分析SQLを書くがフォーマッタは使ったことがないという人は、まずこの後のPrettierの紹介を読んでほしい。きっと使いたくなるはず。
▼ちょっと画質悪いけど、1月23日現在の開発進捗。とりあえず動く。
Prettierとは
Prettier
デフォルトではJavaScript・HTML・CSSなど1に対応したコードフォーマッタ。これを使うと、コマンド一発で以下のようにコードをフォーマットできる。不自然に1行に並べられたコードがきれいに改行・インデントされ、セミコロンが挿入されていることがわかる。
// フォーマット前
const http = require("http");const server = http.createServer((req, res) => {res.end("hello world")});server.listen(3000)
// フォーマット後
const http = require("http");
const server = http.createServer((req, res) => {
res.end("hello world");
});
server.listen(3000);
これを導入すると、例えば以下のようなメリットがある。
- 最小限の労力でチーム内のコーディングスタイルを統一できる
- コードディングスタイルではなくコードの内容に意識と時間を集中できる
分析SQLの文脈だと、例えばチーム内に予約語大文字派と小文字派が共存していても、共有前にPrettierを使うだけでスタイルを揃えることができる。これは時間の節約にも貢献するだろう2。
Prettierプラグイン
Prettierはデフォルトで対応していない言語にも、プラグインで対応できる。SQLもこのパターンで、prettier-plugin-pgというプラグインがあるが、実用段階ではないらしい。それなら最低限機能するプラグインを自分で作ってしまおうというのがこの記事のスタンス。ちなみに利用可能なプラグインはここで紹介されている。
基本的な使い方
インストール
以下のコマンドでインストールできる。プラグインを利用する場合は一緒にインストールするとよい。-g
オプションでグローバルにインストールすることもできるが、あまり推奨されていないようだ。
npm install --save-dev --save-exact prettier
コマンドラインからの利用
prettierをインストールしたプロジェクト内で以下のコマンドを実行する。対象はApp.js
のようなファイル名でもよいし、.
でまとめて指定してもよい。私はまだ試していないが、ドキュメントによるとcommit前に自動で実行することもできるらしい。
npx prettier --write App.js
Vim (Neovim) からの利用
vim-prettierが便利。vim-plugを利用しているなら、以下を*.vimrc* (init.vim)に追記して:PlugInstall
を実行。
call plug#begin('~/.vim/plugged')
" ... other plugins
Plug 'prettier/vim-prettier', { 'do': 'yarn install' }
" ... other plugins
call plug#end()
:Prettier
というのがフォーマットを行うコマンドで、デフォルトだと<leader>p
に割り当てられる。:PrettierAsync
コマンドの方が快適なので、基本こちらを利用するとよさそう。
ちなみにvim-prettierのインストール時にPrettierもインストールされるので、Prettierプラグインが必要ないなら前段のnpm install ...
を省略できる3。
VSCodeからの利用
prettier-vscodeを使う。まずはExtensionsからPrettier - Code formatter
を検索してインストールする。
Prettierを利用することを明示するため、必要に応じてsetting.jsonに以下を記入しておく。
{
"editor.defaultFormatter": "esbenp.prettier-vscode"
}
ここまでで準備は完了。CMD + Shift + P
でコマンドパレットを開き、Format Document
を選択すればフォーマットを実行できる。
ちなみにvim-prettierと同様、prettier-vscodeについても以下のことがいえる。
ちなみにvim-prettierのインストール時にprettierもインストールされるので、Prettierプラグインが必要ないなら前段の
npm install ...
を省略できる3。
Prettierプラグインの実装(分析SQLを例に)
ここからPrettierプラグインの実装方法を紹介する。Prettierのチュートリアルを参考にしているが、さらに簡単な内容に限って記載する。なお、この記事のコードは全てGitHubにあげている。
準備
npmパッケージとして実装していく。まずは以下のコマンドでプロジェクトの初期化と依存パッケージのインストールを行う。
npm init -y
npm install --save prettier node-sql-parser
そしてindex.jsという名称で以下のファイルを作成する。これはチュートリアルで紹介されたコードを参考にしたもので、テンプレだと思ってほしい。printSQL関数のみ後ほど編集する。
const { Parser } = require("node-sql-parser");
const parser = new Parser();
const {
doc: {
builders: { concat, hardline, group, indent, softline, join, line },
},
util,
} = require("prettier");
const languages = [
{
extensions: [".sql"],
name: "sql",
parsers: ["sql-parse"],
},
];
const parsers = {
"sql-parse": {
parse: (text) => parser.astify(text),
astFormat: "sql-ast",
},
};
function printSQL(path, options, print) {
const node = path.getValue();
if (Array.isArray(node)) {
return concat(path.map(print));
}
switch (node.type) {
default:
return "";
}
}
const printers = {
"sql-ast": {
print: printSQL,
},
};
module.exports = {
languages,
parsers,
printers,
};
parserの実装
Prettierでフォーマットを行う際、**テキストは一時的にAST(AbstractSyntaxTree、抽象構文木)に変換される。**この役割を担うのがparserである。今回は自前で実装せずに、node-sql-parserを利用する。以下に簡単な使い方と、出力されるASTを示す。
const { Parser } = require("node-sql-parser");
const parser = new Parser();
const ast = parser.astify("select c1,c2,c3 from data")
console.log(ast)
/*
{
with: null,
type: 'select',
options: null,
distinct: null,
columns: [
{ expr: [Object], as: null },
{ expr: [Object], as: null },
{ expr: [Object], as: null }
],
from: [ { db: null, table: 'data', as: null } ],
where: null,
groupby: null,
having: null,
orderby: null,
limit: null,
for_update: null
}
*/
printerの実装
**ASTをフォーマット済みのコードに変換するのがprinterの役割。**まずはindex.js内のprintSQL関数を再掲する。ASTに対してこの関数を再帰的に適用していくことになる。今はまだ何も出力しない状態だが、処理の流れをコメントで解説しておく。
// printSQLのみ再掲
function printSQL(path, options, print) {
const node = path.getValue(); // pathからASTを抽出
if (Array.isArray(node)) { // nodeが配列の場合、各要素にprintSQL関数を再帰的に適用
return concat(path.map(print)); // path.map(print)が再帰的な呼び出しを表す。戻り値が配列だからconcatで結合
}
switch (node.type) { // node.typeに応じてフォーマット処理をして返す
default:
return "";
}
}
単純なselect文のフォーマットに対応するなら以下のようになる。詳細はコメントで解説しているので見てほしい。難しいのはpath.call(print, "xxx")
path.map(print, "xxx")
だと思うが、printSQL関数を再帰的に適用する定型文のようなもの。
// printSQL修正版
function printSQL(path, options, print) {
const node = path.getValue();
if (Array.isArray(node)) {
return concat(path.map(print));
}
if (node.expr) { // nodeが {expr: {type: "xxx"}} のような形式に対応
return path.call(print, "expr"); // nodeからexprプロパティのみ抽出してprintSQLを適用。path.mapと違って戻り値は文字列(配列ではない)
}
switch (node.type) {
case "select": // nodeが {type: "select"} の場合
return concat([
"SELECT",
indent( // indent()の内側はインデントされる
concat([
hardline, // "SELECT"直後の改行
join(concat([hardline, ","]), path.map(print, "columns")), // nodeからcolumnsプロパティを抽出しprintSQLを適用。各要素は改行とカンマで結合
])
),
hardline, // 改行
"FROM",
indent(concat(node.from.map((x) => concat([hardline, x.table])))),
]);
case "column_ref": // nodeが {type: "column_ref"} の場合
return node.column;
default:
return "";
}
}
まだSELCT句とFROM句のみの単純なSQLしか処理できないが、これでいったん動く。ここまで理解できたら、あとはASTの構造をよく見て処理を充実させるだけ!
動作確認
実装の解説はここまでにして、最後に動作確認を行う。プロジェクトのルートディレクトリ(package.jsonがあるところ)で以下のコードを実行すると、きれいに改行・インデントされ、予約語を大文字に変換したSQLが表示されるはず。
const prettier = require("prettier");
const format = (code) => {
const res = prettier.format(code, {
parser: "sql-parse",
plugins: ["."],
});
return res;
};
console.log(format("select c1,c2,c3 from data"));
/*
SELECT
c1
,c2
,c3
FROM
data
*/
公開
npm publish
でアップロードしたら、既存のPrettierプラグインと同じように利用できる。Prettierプラグインとして認識されるには、パッケージの名称が@prettier/plugin-
prettier-plugin-
@<scope>/prettier-plugin-
のいずれかで始まる必要があるので、package.jsonを適切に編集しておくこと。
最後に
この記事ではSELECT句とFROM句のみのSQLに対応したフォーマッタを作成した。もちろんこれでは業務に使えないので、より実用的なSQLフォーマッタを現在作成中(主にBigQuery用を想定、GitHubはこちら)。進捗としてはある程度複雑なSQLにも対応しつつあるが、node-sql-parserがBigQuery独自の文法には弱かったり、コメントアウトを消してしまったりと実は課題も多い状況にある。parserから再検討するので時間はかかりそうだが、せっかくなので最後まで頑張ってみる(LGTMとかで応援してくれたらもっと頑張る)