tamuraです。
DBUtils3のバージョンアップを行いました。

DBUtils3

今回は

select  
  *
from  
  t_tbl
where  
  id = :id

のようにSQLを定義して

Param param = new Param();  
param.put("id", 123);  
List<ResultBean> result = conn.executeQueryWithParam(ResultBean.class, param);  

のように名前付きパラメータで値を指定できるようにしました。


きっかけ

  • マスタテーブルが5種類ある
  • それぞれに有効期限がある
  • そのマスタと結合させて情報を抜く

というSQLを何個も書いていました。 こんな感じのSQLになります。

select  
  ....
from  
  t_xxxx trn
  m_aaaa m1,
  m_bbbb m2,
  m_cccc m3,
  m_dddd m4
where  
  ...
and  
  m1.start_ymd <= ? and ? < m1.end_ymd
and  
  m2.start_ymd <= ? and ? < m2.end_ymd
and  
  m3.start_ymd <= ? and ? < m3.end_ymd
and  
  m4.start_ymd <= ? and ? < m4.end_ymd

DBUtilsでSQLを発行するとこういうコードになります。

conn.prepare(selectSQL);  
List<ResultBean> result = conn.executeQuery(ResultBean.class,  
    ...,
    ...,
    rangeStartYmd, rangeEndYmd,
    rangeStartYmd, rangeEndYmd,
    rangeStartYmd, rangeEndYmd,
    rangeStartYmd, rangeEndYmd);

どの?に対するパラメータなのか分かりにくくなります。

実装方法

JDBIの実装を参考にして、ANTLRを使って字句解析をしています。 ANTLRのサンプルとしてMySQLのクエリパーサがあったのですが、今回の内容はそこまで複雑ではありません。
また、SQLとしておかしい構文であってもそれはデータベースがおかしいクエリと判断すれば良いため、このツールでは字句解析のみを行っています。

字句解析

字句解析の一部はこんな感じです。 :a:a0などを見つけたらNAMED_PARAMとして呼び出し元に返します。 :0:0aCOLON,NUMBER,ALPHAとして返されます。

ALPHA  
    : 'a'..'z'
    | 'A'..'Z'
    | '_'
    ;
NUMBER  
    : '0'..'9'
    ;
COLON  
    : ':'    
    ;

NAMED_PARAM  
    : COLON ALPHA (ALPHA | NUMBER)*
    ;

呼び出し元はNAMED_PARAMが返されたら?を詰め込む、それ以外が返ってきたら解析された字句をそのまま詰め込む、とすればパラメータを置換してprepareにそのまま渡せる文字列ができあがります。

SQLの解析

ANTLRで作ったLexerでSQLの字句解析を行います。

  • NAMED_PARAMの場合
    • 名前付きパラメータから名前を取得して、リストに詰め込む
    • 組み立て中のSQLは?を入れる
  • NAMED_PARAM以外の場合
    • 組み立て中のSQLに返ってきた字句をそのまま入れる
// 何番目の?がなんというパラメータだったかを保持するリスト
List<String> keyNames;

public String analyzeSQL(String sql) {  
    // ANTLRで生成したLexer
    SQLLexer l = new SQLLexer(new ANTLRInputStream(sql));
    // SQLを生成する
    StrinbBuilder buf = new StringBuilder();
    keyNames = new List<String>();


    Token token = l.nextToken();
    while(token.getType() != SQLLexer.EOF) {
        switch (token.getType()) {
        case SQLLexer.NAMED_PARAM:
            // 先頭のコロンを除去
            keyNames.add(token.getText().substring(1));
            // 名前付きパラメータを?に置換
            buf.append("?");
            break;

        default:
            // そのまま詰め込む
            buf.append(token.getText());
        }
        token = l.nextToken();
    }

    return buf.toString();
}

このメソッドに名前付きパラメータで記述したSQLを渡すと、通常のprepareに渡す形式のSQLに変換します。

select  
  *
from  
  t_tbl
where  
  id = :id

これが

select  
  *
from  
  t_tbl
where  
  id = ?

こうなって、

keyNames:["id"]  

こうなります。

SQLの実行

Mapみたいなクラスを作ってそこにキーと値をセットします。

public class Param {  
    private Map<String, Object> param;

    public Param() {
        param = new HashMap<>();
    }

    public Param put(String key, Object value) {
        param.put(key, value);
        return this;
    }

    public Object get(String key) {
        return param.get(key);
    }
}

putParamを返しているのはこんなことをやりたいためです。

Param param = new Param().put("id", 123).put("name", "tamura");  

このクラスで名前とそのパラメータをセットします。 executeQuery等に渡すため、このパラメータをObject[]に変換する必要があります。

public Object[] createParams(Param param) {  
    List<Object> p = new ArrayList<>(keyNames.size());
    for (String key: keyNames) {
        p.add(param.get(key));
    }
    return (Object[])p.toArray(new Object[]{});
}

最後に、今までのexecuteQueryに横流しします。

public <T> List<T> executeQueryWithParam(Class<T> cls, Params params) throws SQLException {  
    Object[] p = createParams(params);
    return executeQuery(cls, p);
}

まとめ

<dependency>  
    <groupId>com.github.tamurashingo.dbutil3</groupId>
    <artifactId>dbutil3</artifactId>
    <version>0.2.0</version>
</dependency>  

で使うことができます。