[Ruby]重複する文字列を生成するDSLを定義する
はじめに
実案件などで重複する文字列を作成しなければならないことは、よくあるかと思います。多くの場合はエディタを駆使して行うかと思いますが、これが仕様変更等が入る可能性が高いものだったらどうでしょう?プログラマとしては出来るだけ重複した記述は避けたいとは思わないでしょうか?
例えばバッチ処理でのSQLを文字列と捉えると、言語の性質的に共通化が難しく(※1)重複した記述が多くなりがちだと思います。ということでSQLを対象として、重複した記述は出来るだけ避ける為のDSLを定義してみました。
※1 ストアドプロシージャなどは除きます
今回作成したDSLのサンプル
以下、最終的に出力したいSQLと、これを生成するためのSQLです。
出力したいSQL
sample.sql
--SQL① a,b,c,dでグループ化 SELECT col_a ,col_b ,col_c ,col_d ,SUM(col_x) AS x_cnt ,SUM(col_y) AS y_cnt FROM table_a WHERE col_date = '2015/03/01' GROUP BY col_a ,col_b ,col_c ,col_d ; --SQL② a,b,cでグループ化 SELECT col_a ,col_b ,col_c ,SUM(col_x) AS x_cnt ,SUM(col_y) AS y_cnt FROM table_a WHERE col_date = '2015/03/01' GROUP BY col_a ,col_b ,col_c ;
上記のようなSQLを最終的に出力したいとします。2つのSQLを出力していますが、SELECT〜FROM〜WHERE〜GROUP BYという構造と、SUM関数にてサマリーを求めているカラムは共通しています。違う点はコメントと、GROUP BYするカラムだけです。(一つ目は4つのカラムで、二つ目は3つのカラムでGROUP BYしている)
DSLの例(1)
このSQLを出力するのに以下の様なDSLを書くようにしました。
exec_sample.rb
require "./lib/query"
SQL_PATH = "./sample.sql"
SUM_COLS = <<"EOS" ,SUM(col_x) AS x_cnt ,SUM(col_y) AS y_cnt EOS FROM_TABLE = <<"EOS" FROM table_a EOS WHERE_COL = <<"EOS" WHERE col_date = '2015/03/01' EOS GROUP_COLS_ABCD = <<"EOS" col_a ,col_b ,col_c ,col_d EOS GROUP_COLS_ABC = <<"EOS" col_a ,col_b ,col_c EOS def main Query.path = SQL_PATH Query.initialize Query.generate do add "--SQL① a,b,c,dでグループ化" add "SELECT" add GROUP_COLS_ABCD add SUM_COLS add FROM_TABLE add WHERE_COL add "GROUP BY" add GROUP_COLS_ABCD add ";" add "" end Query.generate do add "--SQL② a,b,cでグループ化" add "SELECT" add GROUP_COLS_ABC add SUM_COLS add FROM_TABLE add WHERE_COL add "GROUP BY" add GROUP_COLS_ABC add ";" add "" end Query.write end main [/ruby] 5〜31行目でSQLに必要な各「パーツ」を定義しています。これらはヒアドキュメントを使用しているため、実際のSQLに近い形で定義できています。34・35行目では出力するSQLのパスを渡し、初期化を行っています。DSLとなっているのは37〜61行目で、Query.generateのブロック内でaddを呼び出し、SQLとして出力する文字列を追加しています。最後に63行目でQuery.writeを呼び出し、SQLを実際に出力しています。
SQLを出力するソース
上記のDSLを解釈し、SQLを出力するソースは以下のようになります。
lib/query.rb
require "fileutils"
module Query class << self def path=(p) @path = p end def initialize @lines = Array.new File.open(@path, "w").close end def generate(&block) instance_eval(&block) end def add(line) @lines << line end def write File.open(@path, "a") do |f| @lines.each { |line| f.puts(line) } end end end end [/ruby] 特徴的なのはDSLを実現するための「generate」メソッドだと思います。「&block」で引数としてブロックを受け取り、ブロックを「instance_eval」に渡すことでブロック内に記述されたメソッドを実行しています。DSLのブロック内に記述された「add」メソッドは、引数を配列に追加しているだけです。後は「write」メソッドで、配列の中身を一行ずつ出力しています。
DSLの例(2)
ここまででDSLで定義したSQLを出力することはできました(※2)。が、SELECT〜FROM〜WHERE〜GROUP BYという構造が重複して記述されていることが気になります。そこでこの部分をメソッドとして共通化してみました。
exec_sample.rb
require "./lib/query"
SQL_PATH = "./sample.sql"
SUM_COLS = <<"EOS" ,SUM(col_x) AS x_cnt ,SUM(col_y) AS y_cnt EOS FROM_TABLE = <<"EOS" FROM table_a EOS WHERE_COL = <<"EOS" WHERE col_date = '2015/03/01' EOS GROUP_COLS_ABCD = <<"EOS" col_a ,col_b ,col_c ,col_d EOS GROUP_COLS_ABC = <<"EOS" col_a ,col_b ,col_c EOS def generate_select_sql(comment, columns) Query.generate do add comment add "SELECT" add columns add SUM_COLS add FROM_TABLE add WHERE_COL add "GROUP BY" add columns add ";" add "" end end def main Query.path = SQL_PATH Query.initialize generate_select_sql("--SQL① a,b,c,dでグループ化", GROUP_COLS_ABCD) generate_select_sql("--SQL② a,b,cでグループ化", GROUP_COLS_ABC) Query.write end main [/ruby] 「Query.generate do 〜 end」の記述を「generate_select_sql」メソッドとして独立させました。引数に2つのSQLで異なる部分(コメント、GROUP BYするカラム)を渡しています。可読性は落ちるかもしれませんが、重複した定義は省けたかと思います。
※2 実行するコマンドは以下になります。
$ ruby exec_sample.rb
まとめ
DSLの部分だけ見ると、あまりRubyを意識することなく書くことが出来るかと思います。これにより一度DSLを作ってしまえば、Ruby開発者でなくても使用することができると言えると思います。また今回はSQLを出力してみましたが、定義ファイルやCSVファイルなどの出力も同じ様なロジックで出力できるかと思います。
重複した定義を出力する時など、何かの参考になれば幸いです。